homer.py 28.1 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
import sys
import getopt
import urllib.parse
import time
12
import socket
13
import os.path
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
14

Alexandre's avatar
Alexandre committed
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
try:
    # http://pycurl.io/docs/latest
    import pycurl

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

    # 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
except ImportError as e:
    print("Error: missing module")
    print(e)
    sys.exit(1)

Alexandre's avatar
Alexandre committed
31
32
import homer

33
# Values that can be changed from the command line
Alexandre's avatar
Alexandre committed
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class opts:
    dot = False # DoH by default
    verbose = False
    debug = False
    insecure = False
    post = False
    head = False
    dnssec = False
    edns = True
    no_ecs = True
    sni = True
    rtype = 'AAAA'
    vhostname = None
    tests = 1 # Number of repeated tests
    key = None # SPKI
    ifile = None # Input file
    delay = None
    forceIPv4 = False
    forceIPv6 = False
    connectTo = None
    pipelining = False
    max_in_flight = 20
    multistreams = False
    display_results = True
    show_time = False
    check = False
    mandatory_level = None
    check_additional = True
    # Monitoring plugin only:
    host = None
    path = None
    expect = None
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
66

67
68
69
70
71
72
73
# For the monitoring plugin
STATE_OK = 0
STATE_WARNING = 1
STATE_CRITICAL = 2
STATE_UNKNOWN = 3
STATE_DEPENDENT = 4

74
def error(msg=None, exit=True):
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
75
76
    if msg is None:
        msg = "Unknown error"
77
78
    if monitoring:
        print("%s: %s" % (url, msg))
79
80
        if exit:
            sys.exit(STATE_CRITICAL)
81
    else:
Alexandre's avatar
Alexandre committed
82
        print(msg, file=sys.stderr)
Alexandre's avatar
Alexandre committed
83
        if opts.check:
Alexandre's avatar
Alexandre committed
84
            print('KO')
85
86
        if exit:
            sys.exit(1)
Alexandre's avatar
Alexandre committed
87

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
88
89
90
def usage(msg=None):
    if msg:
        print(msg,file=sys.stderr)
Alexandre's avatar
Alexandre committed
91
92
93
    print("Usage: %s [options] url-or-servername [domain-name [DNS type]]" % sys.argv[0], file=sys.stderr)
    print("""Options
    -t --dot            Use DoT (by default use DoH)
94
95
96
97
98
    -k --insecure       Do not check the certificate
    -4 --v4only         Force IPv4 resolution of url-or-servername
    -6 --v6only         Force IPv6 resolution of url-or-servername
    -v --verbose        Make the program more talkative
    --debug             Make the program even more talkative than -v
Alexandre's avatar
Alexandre committed
99
    -r --repeat <N>     Perform N times the query. If used with -f, read up to
100
                        <N> lines of the <file>
Alexandre's avatar
Alexandre committed
101
102
103
    -d --delay <T>      Time to wait in seconds between each synchronous
                        request (only with --repeat)
    -f --file <file>    Read domain names from <file>, one per row with an
104
105
106
                        optional DNS type. Read the first line only, use
                        --repeat N to read up to N lines of the file
    --check             Perform a set of predefined tests
Alexandre's avatar
Alexandre committed
107
108
109
110
111
    --mandatory-level <level>
                        Define the <level> of test to perform (only with
                        --check)
                        Available <level> : legal, necessary, nicetohave
    --no-display-results
112
                        Disable output of DNS response
Alexandre's avatar
Alexandre committed
113
114
115
116
    --dnssec            Request DNSSEC data (signatures)
    --noedns            Disable EDNS, default is to indicate EDNS support
    --ecs               Send ECS to authoritative servers, default is to
                        refuse it
Alexandre's avatar
Alexandre committed
117
    -V --vhost <vhost>  Use a specific virtual host
Alexandre's avatar
Alexandre committed
118
119
    -h --help           Print this message

120
121
122
123
124
125
126
127
128
129
130
  DoH only options:
    -P --post --POST    Use HTTP POST method for all the transfers
    -e --head --HEAD    Use HTTP HEAD method for all the transfers
    --multistreams      Use HTTP/2 streams, needs an input file with -f
    --time              Display the time elapsed for the query (only with
                        --multistreams)

  DoT only options:
    --key <key>         Authenticate a DoT resolver with its public <key> in
                        base64
    --nosni             Do not perform SNI
Alexandre's avatar
Alexandre committed
131
132
133
    --pipelining        Pipeline the requests, needs an input file with -f
    --max-in-flight <M> Maximum number of concurrent requests in parallel (only
                        with --pipelining)
134

Alexandre's avatar
Alexandre committed
135
136
137
138
139
    url-or-servername   The URL or domain name of the DoT/DoH server
    domain-name         The domain name to resolve, not required if -f is
                        provided
    DNS type            The DNS record type to resolve, default AAAA
    """, file=sys.stderr)
140
    print("See the README.md for more details.", file=sys.stderr)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
141

Alexandre's avatar
Alexandre committed
142
143
144
145
def get_next_domain(input_file):
    name, rtype = 'framagit.org', 'AAAA'
    line = input_file.readline()
    if line[:-1] == "":
Alexandre's avatar
Alexandre committed
146
        error("Not enough data in %s for the %i tests" % (opts.ifile, opts.tests))
Alexandre's avatar
Alexandre committed
147
148
149
150
151
152
153
    if line.find(' ') == -1:
        name = line[:-1]
        rtype = 'AAAA'
    else:
        (name, rtype) = line.split()
    return name, rtype

154
def print_result(connection, request, prefix=None, display_err=True):
Alexandre's avatar
Alexandre committed
155
    ok = request.ok
156
157
158
159
160
161
162
    dot = connection.dot
    server = connection.server
    rcode = request.rcode
    msg = request.response
    size = request.response_size
    if (dot and rcode) or (not dot and rcode == 200):
        if not monitoring:
Alexandre's avatar
Alexandre committed
163
            if not opts.dot and opts.show_time:
164
                connection.print_time(connection.curl_handle)
Alexandre's avatar
Alexandre committed
165
            if opts.display_results and (not opts.check or opts.verbose):
166
167
                print(msg)
        else:
Alexandre's avatar
Alexandre committed
168
            if opts.expect is not None and opts.expect not in str(request.response):
169
                ok = False
Alexandre's avatar
Alexandre committed
170
                print("%s Cannot find \"%s\" in response" % (server, opts.expect))
171
                sys.exit(STATE_CRITICAL)
172
            if ok and size is not None and size > 0:
173
                print("%s OK - %s" % (server, "No error for %s/%s, %i bytes received" % (name, opts.rtype, size)))
174
175
176
177
178
            else:
                print("%s OK - %s" % (server, "No error"))
            sys.exit(STATE_OK)
    else:
        if not monitoring:
179
            if display_err:
Alexandre's avatar
Alexandre committed
180
                if opts.check:
181
                    print(connection.connect_to, end=': ', file=sys.stderr)
182
183
184
185
186
187
188
189
190
191
                if prefix:
                    print(prefix, end=': ', file=sys.stderr)
                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) 
192
        else:
193
194
195
196
197
198
199
200
            if not dot:
                print("%s HTTP error - %i: %s" % (server, rcode, msg))
            else:
                print("%s Error - %i: %s" % (server, rcode, msg))
            sys.exit(STATE_CRITICAL)
        ok = False
    return ok

201
def run_check_default(connection):
Alexandre's avatar
Alexandre committed
202
    ok = True
Alexandre's avatar
Alexandre committed
203
    req_args = { 'qname': name, 'qtype': opts.rtype, 'use_edns': opts.edns, 'want_dnssec': opts.dnssec, 'no_ecs': opts.no_ecs}
Alexandre's avatar
Alexandre committed
204
    requests = homer.create_requests_list(dot=connection.dot, **req_args)
205
    for request_pack in requests:
Alexandre's avatar
Alexandre committed
206
        if connection.dot:
207
            test_name, request, level = request_pack
208
        else:
209
            test_name, request, method, level = request_pack
Alexandre's avatar
Alexandre committed
210
        if connection.verbose:
211
            print(test_name)
Alexandre's avatar
Alexandre committed
212
        if connection.dot:
Alexandre's avatar
Alexandre committed
213
214
            bundle = request
        else:
Alexandre's avatar
Alexandre committed
215
            if method == homer.DOH_POST:
216
                request.post = True
Alexandre's avatar
Alexandre committed
217
            elif method == homer.DOH_HEAD:
218
                request.head = True
219
220
221
            handle = connection.curl_handle
            handle.prepare(handle, connection, request)
            bundle = handle
Alexandre's avatar
Alexandre committed
222
223
224
225
226
227
228
229
230
231
        try:
            connection.send_and_receive(bundle)
        except (homer.ConnectionException, homer.DOHException) as e:
            # GET and POST are mandatory and therefore if an error is caught
            # here, print it and exit
            if method == homer.DOH_GET or method == homer.DOH_POST:
                error(e)
            else:
                print(e, file=sys.stderr)
                return False
Alexandre's avatar
Alexandre committed
232
        request.check_response(connection.debug)
233
        if not print_result(connection, request, prefix=test_name, display_err=False):
Alexandre's avatar
Alexandre committed
234
            if level >= opts.mandatory_level:
235
236
                print_result(connection, request, prefix=test_name, display_err=True)
                ok = False
Alexandre's avatar
Alexandre committed
237
            if connection.verbose:
Alexandre's avatar
Alexandre committed
238
                print()
239
            break
Alexandre's avatar
Alexandre committed
240
        if connection.verbose:
Alexandre's avatar
Alexandre committed
241
            print()
Alexandre's avatar
Alexandre committed
242
    return ok
243

244
def run_check_mime(connection, accept="application/dns-message", content_type="application/dns-message"):
Alexandre's avatar
Alexandre committed
245
246
247
    # change the MIME value and see what happens
    # based on the RFC only application/dns-message must be supported, any
    # other MIME type can be also supported, but nothing is said on that
Alexandre's avatar
Alexandre committed
248
    if connection.dot:
249
250
251
        return True
    ok = True
    header = [f"Accept: {accept}", f"Content-type: {content_type}"]
Alexandre's avatar
Alexandre committed
252
    if connection.verbose:
Alexandre's avatar
Alexandre committed
253
254
        test_name = f'Test mime: {", ".join(h for h in header)}'
        print(test_name)
Alexandre's avatar
Alexandre committed
255
    req_args = { 'qname': name, 'qtype': opts.rtype, 'use_edns': opts.edns, 'want_dnssec': opts.dnssec, 'no_ecs': opts.no_ecs}
Alexandre's avatar
Alexandre committed
256
    request = homer.create_request(**req_args)
257
258
259
    handle = connection.curl_handle
    handle.setopt(pycurl.HTTPHEADER, header)
    handle.prepare(handle, connection, request)
Alexandre's avatar
Alexandre committed
260
261
262
263
264
    try:
        connection.send_and_receive(handle)
    except (homer.ConnectionException, homer.DOHException) as e:
        print(e, file=sys.stderr)
        return False
Alexandre's avatar
Alexandre committed
265
    request.check_response(connection.debug)
266
267
268
269
    if not print_result(connection, request, prefix=f"Test Header {', '.join(header)}"):
        ok = False
    default = "application/dns-message"
    default_header = [f"Accept: {default}", f"Content-type: {default}"]
270
    handle.setopt(pycurl.HTTPHEADER, default_header)
Alexandre's avatar
Alexandre committed
271
    if connection.verbose:
Alexandre's avatar
Alexandre committed
272
        print()
273
274
    return ok

275
def run_check_trunc(connection):
Alexandre's avatar
Alexandre committed
276
277
278
279
    # send truncated DNS request to the server and expect a HTTP return code
    # either equal to 200 or in the 400 range
    # in case the server answers with 200, look for a FORMERR error in the DNS
    # response
280
281
    ok = True
    test_name = 'Test truncated data'
Alexandre's avatar
Alexandre committed
282
    if opts.verbose:
283
        print(test_name)
Alexandre's avatar
Alexandre committed
284
    req_args = { 'qname': name, 'qtype': opts.rtype, 'use_edns': opts.edns, 'want_dnssec': opts.dnssec, 'no_ecs': opts.no_ecs}
Alexandre's avatar
Alexandre committed
285
    if connection.dot:
Alexandre's avatar
Alexandre committed
286
        request = homer.create_request(dot=connection.dot, trunc=True, **req_args)
287
        bundle = request
288
    else:
Alexandre's avatar
Alexandre committed
289
        request = homer.create_request(trunc=True, **req_args)
290
        request.post = True
291
292
293
        handle = connection.curl_handle
        handle.prepare(handle, connection, request)
        bundle = handle
294
    try:
295
        # 8.8.8.8 replies FORMERR but most DoT servers violently shut down the connection (which is legal)
Alexandre's avatar
Alexandre committed
296
        connection.send_and_receive(bundle, dump=connection.debug)
297
298
    except OpenSSL.SSL.ZeroReturnError: # This is acceptable
        return ok
299
300
301
302
303
    except dns.exception.FormError: # This is also acceptable
        # Some DSN resolvers will echo mangled requests with
        # the RCODE set to FORMERR
        # so response can not be parsed in this case
        return ok
Alexandre's avatar
Alexandre committed
304
305
306
    except homer.DOHException as e:
        print(e, file=sys.stderr)
        return False
Alexandre's avatar
Alexandre committed
307
308
    if request.check_response(connection.debug): # FORMERR is expected
        if connection.dot:
Alexandre's avatar
Alexandre committed
309
310
311
            ok = request.rcode == dns.rcode.FORMERR
        else:
            ok = (request.response.rcode() == dns.rcode.FORMERR)
312
    else:
Alexandre's avatar
Alexandre committed
313
        if connection.dot:
Alexandre's avatar
Alexandre committed
314
            ok = False
Alexandre's avatar
Alexandre committed
315
316
317
318
319
        else: # only a 400 range HTTP code is acceptable
              # if we send garbage to the server, it seems reasonable that it
              # does not fail, which means we don't accept a 500 range HTTP
              # error code (even so it means the server failed to process the
              # input data)
Alexandre's avatar
Alexandre committed
320
321
            ok = (request.rcode >= 400 and request.rcode < 500)
    print_result(connection, request, prefix=test_name, display_err=not ok)
Alexandre's avatar
Alexandre committed
322
    if connection.verbose:
Alexandre's avatar
Alexandre committed
323
        print()
324
325
    return ok

326
def run_check_additionals(connection):
327
328
    if not run_check_trunc(connection):
        return False
329
330
331
332
333
    # The DoH server is right to reject these (Example: 'HTTP
    # error 415: only Content-Type: application/dns-message is
    # supported')
    run_check_mime(connection, accept="text/html")
    run_check_mime(connection, content_type="text/html")
334
335
336
337
338
    return True

def run_check(connection):
    if not run_check_default(connection):
        return False
Alexandre's avatar
Alexandre committed
339
    if opts.check_additional and not run_check_additionals(connection):
340
341
342
        return False
    return True

Alexandre's avatar
Alexandre committed
343
def resolved_ips(host, port, family, dot=False):
344
345
346
    try:
        addr_list = socket.getaddrinfo(host, port, family)
    except socket.gaierror:
347
        error("Could not resolve \"%s\"" % host)
348
349
    ip_set = { addr[4][0] for addr in addr_list }
    return ip_set
Alexandre's avatar
Alexandre committed
350

351
def parse_opts(opts):
352
    name = None
353
354
    rtype = opts.rtype

355
    try:
356
        optlist, args = getopt.getopt (sys.argv[1:], "hvPkeV:r:f:d:t46",
357
                                       ["help", "verbose", "debug", "dot",
Alexandre's avatar
Alexandre committed
358
359
                                        "head", "HEAD", "post", "POST",
                                        "insecure", "vhost=", "multistreams",
360
                                        "pipelining", "max-in-flight=", "key=",
361
                                        "dnssec", "noedns", "ecs", "nosni",
362
                                        "no-display-results", "time",
363
                                        "file=", "repeat=", "delay=",
364
                                        "v4only", "v6only",
365
                                        "check", "mandatory-level="])
366
367
368
369
        for option, value in optlist:
            if option == "--help" or option == "-h":
                usage()
                sys.exit(0)
370
            elif option == "--dot" or option == "-t":
Alexandre's avatar
Alexandre committed
371
                opts.dot = True
372
            elif option == "--verbose" or option == "-v":
Alexandre's avatar
Alexandre committed
373
                opts.verbose = True
Alexandre's avatar
Alexandre committed
374
            elif option == "--debug":
Alexandre's avatar
Alexandre committed
375
376
                opts.debug = True
                opts.verbose = True
377
            elif option == "--HEAD" or option == "--head" or option == "-e":
Alexandre's avatar
Alexandre committed
378
                opts.head = True
379
            elif option == "--POST" or option == "--post" or option == "-P":
Alexandre's avatar
Alexandre committed
380
                opts.post = True
381
            elif option == "--vhost" or option == "-V":
Alexandre's avatar
Alexandre committed
382
                opts.vhostname = value
383
            elif option == "--insecure" or option == "-k":
Alexandre's avatar
Alexandre committed
384
                opts.insecure = True
385
            elif option == "--multistreams":
Alexandre's avatar
Alexandre committed
386
                opts.multistreams = True
387
            elif option == "--no-display-results":
Alexandre's avatar
Alexandre committed
388
                opts.display_results = False
389
            elif option == "--time":
Alexandre's avatar
Alexandre committed
390
                opts.show_time = True
391
            elif option == "--dnssec":
Alexandre's avatar
Alexandre committed
392
                opts.dnssec = True
393
            elif option == "--nosni":
Alexandre's avatar
Alexandre committed
394
                opts.sni = False
395
396
397
398
            elif option == "--noedns": # Warning: it will mean the
                                       # resolver may send ECS
                                       # information to the
                                       # authoritative name servers.
Alexandre's avatar
Alexandre committed
399
                opts.edns = False
400
            elif option == "--ecs":
Alexandre's avatar
Alexandre committed
401
                opts.no_ecs = False
402
            elif option == "--repeat" or option == "-r":
Alexandre's avatar
Alexandre committed
403
404
                opts.tests = int(value)
                if opts.tests <= 1:
405
406
                    error("--repeat needs a value > 1")
            elif option == "--delay" or option == "-d":
Alexandre's avatar
Alexandre committed
407
                opts.delay = float(value)
408
409
410
                if delay <= 0:
                    error("--delay needs a value > 0")
            elif option == "--file" or option == "-f":
Alexandre's avatar
Alexandre committed
411
                opts.ifile = value
412
            elif option == "--key":
Alexandre's avatar
Alexandre committed
413
                opts.key = value
414
            elif option == "-4" or option == "--v4only":
Alexandre's avatar
Alexandre committed
415
                opts.forceIPv4 = True
416
            elif option == "-6" or option == "--v6only":
Alexandre's avatar
Alexandre committed
417
                opts.forceIPv6 = True
418
            elif option == "--pipelining":
Alexandre's avatar
Alexandre committed
419
                opts.pipelining = True
420
            elif option == "--max-in-flight":
Alexandre's avatar
Alexandre committed
421
                opts.max_in_flight = int(value)
422
423
424
                if max_in_flight <= 0:
                    error("--max_in_flight but be > 0")
                if max_in_flight >= 65536:
425
                    error("Because of a limit of the DNS protocol (the size of the query ID) --max_in_flight must be < 65 536")
426
            elif option == "--check":
Alexandre's avatar
Alexandre committed
427
428
                opts.check = True
                opts.display_results = False
429
            elif option == "--mandatory-level":
Alexandre's avatar
Alexandre committed
430
                opts.mandatory_level = value
431
432
            else:
                error("Unknown option %s" % option)
Alexandre's avatar
Alexandre committed
433
    except (getopt.error, ValueError) as reason:
434
        error(reason)
435
        sys.exit(1)
436

Alexandre's avatar
Alexandre committed
437
    if opts.delay is not None and opts.multistreams:
Alexandre's avatar
Alexandre committed
438
        error("--delay makes no sense with multistreams")
Alexandre's avatar
Alexandre committed
439
    if opts.tests <= 1 and opts.delay is not None:
440
        error("--delay makes no sense if there is no repetition")
Alexandre's avatar
Alexandre committed
441
    if not opts.dot and opts.pipelining:
442
        error("Pipelining is only accepted for DoT")
443
        sys.exit(1)
Alexandre's avatar
Alexandre committed
444
    if opts.dot and (opts.post or opts.head):
445
        error("POST or HEAD makes non sense for DoT")
446
        sys.exit(1)    
Alexandre's avatar
Alexandre committed
447
    if opts.post and opts.head:
448
        error("POST or HEAD but not both")
449
        sys.exit(1)
Alexandre's avatar
Alexandre committed
450
    if opts.pipelining and opts.ifile is None:
451
        error("Pipelining requires an input file")
452
        sys.exit(1)
Alexandre's avatar
Alexandre committed
453
    if opts.check and opts.multistreams:
454
        error("--check and --multistreams are not compatible")
Alexandre's avatar
Alexandre committed
455
        sys.exit(1)
Alexandre's avatar
Alexandre committed
456
    if opts.dot and opts.multistreams:
457
        error("Multi-streams makes no sense for DoT")
458
        sys.exit(1)
Alexandre's avatar
Alexandre committed
459
    if opts.multistreams and opts.ifile is None:
460
        error("Multi-streams requires an input file")
461
        sys.exit(1)
Alexandre's avatar
Alexandre committed
462
    if opts.show_time and opts.dot:
463
        error("--time cannot be used with --dot")
464
        sys.exit(1)
Alexandre's avatar
Alexandre committed
465
    if not opts.edns and not opts.no_ecs:
466
        error("ECS requires EDNS")
467
        sys.exit(1)
Alexandre's avatar
Alexandre committed
468
    if opts.mandatory_level is not None and \
Alexandre's avatar
Alexandre committed
469
       opts.mandatory_level not in homer.mandatory_levels.keys():
Alexandre's avatar
Alexandre committed
470
        error("Unknown mandatory level \"%s\"" % opts.mandatory_level)
471
        sys.exit(1)
Alexandre's avatar
Alexandre committed
472
    if opts.mandatory_level is not None and not opts.check:
473
        error("--mandatory-level only makes sense with --check")
474
        sys.exit(1)
Alexandre's avatar
Alexandre committed
475
476
    if opts.mandatory_level is None:
        opts.mandatory_level = "necessary"
Alexandre's avatar
Alexandre committed
477
    opts.mandatory_level = homer.mandatory_levels[opts.mandatory_level]
Alexandre's avatar
Alexandre committed
478
    if opts.ifile is None and (len(args) != 2 and len(args) != 3):
479
        error("Wrong number of arguments")
480
        sys.exit(1)
Alexandre's avatar
Alexandre committed
481
    if opts.ifile is not None and len(args) != 1:
482
        error("Wrong number of arguments (if --file is used, do not indicate the domain name)")
483
484
        sys.exit(1)
    url = args[0]
Alexandre's avatar
Alexandre committed
485
    if opts.ifile is None:
486
487
        name = args[1]
        if len(args) == 3:
488
            opts.rtype = args[2]
489

490
    return (url, name)
491
492

def parse_opts_monitoring(me, opts):
493
    name = None
494
495
496
    opts.dot = (me == "check_dot")
    rtype = opts.rtype

497
    try:
498
        optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:e:Pih46k:x")
499
500
        for option, value in optlist:
            if option == "-H":
Alexandre's avatar
Alexandre committed
501
                opts.host = value
502
            elif option == "-V":
Alexandre's avatar
Alexandre committed
503
                opts.vhostname = value
504
505
506
            elif option == "-n":
                name = value
            elif option == "-t":
Alexandre's avatar
Alexandre committed
507
                opts.rtype = value
508
            elif option == "-e":
Alexandre's avatar
Alexandre committed
509
                opts.expect = value
510
            elif option == "-p":
Alexandre's avatar
Alexandre committed
511
                opts.path = value
512
            elif option == "-P":
Alexandre's avatar
Alexandre committed
513
                opts.post = True
514
            elif option == "-h":
Alexandre's avatar
Alexandre committed
515
                opts.head = True
516
            elif option == "-i":
Alexandre's avatar
Alexandre committed
517
                opts.insecure = True
518
            elif option == "-x":
Alexandre's avatar
Alexandre committed
519
                opts.sni = False
520
            elif option == "-4":
Alexandre's avatar
Alexandre committed
521
                opts.forceIPv4 = True
522
            elif option == "-6":
Alexandre's avatar
Alexandre committed
523
                opts.forceIPv6 = True
524
            elif option == "-k":
Alexandre's avatar
Alexandre committed
525
                opts.key = value
526
527
528
529
530
531
532
            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)
533

534
535
536
    if len(args) > 0:
        print("Too many arguments (\"%s\")" % args)
        sys.exit(STATE_UNKNOWN)
Alexandre's avatar
Alexandre committed
537
    if opts.host is None or name is None:
538
539
        print("Host (-H) and name to lookup (-n) are necessary")
        sys.exit(STATE_UNKNOWN)
Alexandre's avatar
Alexandre committed
540
    if opts.post and opts.head:
541
542
        print("POST or HEAD but not both")
        sys.exit(STATE_UNKNOWN)
Alexandre's avatar
Alexandre committed
543
    if opts.dot and (opts.post or opts.head):
544
545
        print("POST or HEAD makes no sense for DoT")
        sys.exit(STATE_UNKNOWN)
Alexandre's avatar
Alexandre committed
546
    if opts.dot and opts.path:
547
548
        print("URL path makes no sense for DoT")
        sys.exit(STATE_UNKNOWN)
Alexandre's avatar
Alexandre committed
549
550
    if opts.dot:
        url = opts.host
551
    else:
Alexandre's avatar
Alexandre committed
552
553
554
        if opts.vhostname is None or opts.vhostname == opts.host:
            opts.connectTo = None
            url = "https://%s/" % opts.host
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
555
        else:
Alexandre's avatar
Alexandre committed
556
557
558
559
560
561
            opts.connectTo = opts.host
            url = "https://%s/" % opts.vhostname
        if opts.path is not None:
            if opts.path.startswith("/"):
                opts.path = opts.path[1:]
            url += opts.path
562

563
    return (url, name)
564

Alexandre's avatar
Alexandre committed
565
566
def run_default(name, connection, opts):
    ok = True
567
    start = time.time()
Alexandre's avatar
Alexandre committed
568
569
570
571
572
573
574
    if opts.multistreams:
        connection.init_multi()
    for i in range (0, opts.tests):
        if opts.tests > 1 and (opts.verbose or opts.display_results):
            print("\nTest %i" % i)
        if opts.ifile is not None:
            name, opts.rtype = get_next_domain(input)
Alexandre's avatar
Alexandre committed
575
        request = homer.create_request(name, qtype=opts.rtype, use_edns=opts.edns,
Alexandre's avatar
Alexandre committed
576
577
578
579
580
581
582
583
                     want_dnssec=opts.dnssec, no_ecs=opts.no_ecs, dot=opts.dot)
        request.i = i
        if not opts.dot:
            request.head = opts.head
            request.post = opts.post
        if not opts.pipelining:
            try:
                connection.do_test(request, synchronous = not opts.multistreams)
Alexandre's avatar
Alexandre committed
584
            except (OpenSSL.SSL.Error, homer.DOHException) as e:
Alexandre's avatar
Alexandre committed
585
586
587
588
589
590
591
592
593
594
595
596
597
                ok = False
                error(e)
                break
            if not opts.multistreams:
                if not print_result(connection, request):
                    ok = False
            if opts.tests > 1 and i == 0:
                start2 = time.time()
            if opts.delay is not None:
                time.sleep(opts.delay)
        else: # We do pipelining
            connection.pipelining_add_request(request)
    if opts.multistreams:
598
        connection.perform_multi(opts.show_time, display_results=opts.display_results)
Alexandre's avatar
Alexandre committed
599
600
601
602
603
    if opts.dot and opts.pipelining:
        print("")
        done = 0
        current = connection.pipelining_init_pending(opts.max_in_flight)
        while done < opts.tests:
Alexandre's avatar
Alexandre committed
604
            if time.time() > start + homer.MAX_DURATION: # if we send thousands of requests
Alexandre's avatar
Alexandre committed
605
606
607
608
609
610
611
612
                                                   # MAX_DURATION will be reached
                                                   # need to increase MAX_DURATION based
                                                   # on the number of queries
                                                   # or to define a relation such as
                                                   # f(tests) = MAX_DURATION
                print("Elapsed time too long, %i requests never got a reply" % (opts.tests-done))
                ok = False
                break
613
            id = connection.read_result(connection, connection.pending, display_results=opts.display_results)
Alexandre's avatar
Alexandre committed
614
            if id is None: # Probably a timeout
Alexandre's avatar
Alexandre committed
615
                time.sleep(homer.SLEEP_TIMEOUT)
Alexandre's avatar
Alexandre committed
616
617
618
619
620
621
622
623
                continue
            done += 1
            over, rank, request = connection.pending[id]
            if not over:
                error("Internal error, request %i should be over" % id)
            if current < len(connection.all_requests):
                connection.pipelining_fill_pending(current)
                current += 1
624
625
626
627
628
629
630
631
632
633
634
635
    stop = time.time()
    if opts.tests > 1 and not opts.pipelining and not opts.multistreams:
        extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(opts.tests-1))
    else:
        extra = ""
    if not monitoring and (not opts.check or opts.verbose):
        time_tot = stop - start
        time_per_request = time_tot / opts.tests * 1000
        print("\nTotal elapsed time: %.2f seconds (%.2f ms/request%s)" % (time_tot, time_per_request, extra))
    if opts.multistreams and opts.verbose:
        for rcode, n in conn.finished['http'].items():
            print("HTTP %d : %d %.2f%%" % (rcode, n, n / opts.tests * 100))
Alexandre's avatar
Alexandre committed
636
637
    return ok

638
639
640
641
642
643
644
645
# Main program
me = os.path.basename(sys.argv[0])

# Are we using the script as monitor ?
# the monitoring code should move somewhere else
monitoring = (me == "check_doh" or me == "check_dot")

if not monitoring:
646
    url, name = parse_opts(opts)
647
else: # Monitoring plugin
648
    url, name = parse_opts_monitoring(me, opts)
649

650
651
652
# retrieve all ips when using --check
# not necessary if connectTo is already defined
# as it is the case with --monitoring
Alexandre's avatar
Alexandre committed
653
654
if not opts.check or opts.connectTo is not None:
    ip_set = {opts.connectTo, }
655
else:
Alexandre's avatar
Alexandre committed
656
    if opts.dot:
657
        port = homer.PORT_DOT
Alexandre's avatar
Alexandre committed
658
        if not homer.is_valid_hostname(url):
659
660
661
            error("DoT requires a host name or IP address, not \"%s\"" % url)
        netloc = url
    else:
662
        port = homer.PORT_DOH
Alexandre's avatar
Alexandre committed
663
        if not homer.is_valid_url(url):
664
            error("DoH requires a valid HTTPS URL, not \"%s\"" % url)
Alexandre's avatar
Alexandre committed
665
666
667
668
669
        try:
            url_parts = urllib.parse.urlparse(url) # A very poor validation, many
            # errors (for instance whitespaces, IPv6 address litterals without
            # brackets...) are ignored.
        except ValueError:
670
            error("The provided url \"%s\" could not be parsed" % url)
671
        netloc = url_parts.netloc
Alexandre's avatar
Alexandre committed
672
    if opts.forceIPv4:
673
        family = socket.AF_INET
Alexandre's avatar
Alexandre committed
674
    elif opts.forceIPv6:
675
676
677
        family = socket.AF_INET6
    else:
        family = 0
Alexandre's avatar
Alexandre committed
678
    ip_set = resolved_ips(netloc, port, family, opts.dot)
679

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
680
ok = True
Alexandre's avatar
Alexandre committed
681
for ip in ip_set:
Alexandre's avatar
Alexandre committed
682
683
    if opts.dot and opts.vhostname is not None:
        extracheck = opts.vhostname
684
685
    else:
        extracheck = None
Alexandre's avatar
Alexandre committed
686
    if opts.verbose and opts.check and ip:
687
        print("Checking \"%s\" on %s ..." % (url, ip))
688
    try:
Alexandre's avatar
Alexandre committed
689
        if opts.dot:
Alexandre's avatar
Alexandre committed
690
            conn = homer.ConnectionDOT(url, servername=extracheck, connect_to=ip,
691
692
693
                                 forceIPv4=opts.forceIPv4, forceIPv6=opts.forceIPv6,
                                 insecure=opts.insecure, verbose=opts.verbose, debug=opts.debug,
                                 sni=opts.sni, key=opts.key, pipelining=opts.pipelining)
694
        else:
Alexandre's avatar
Alexandre committed
695
            conn = homer.ConnectionDOH(url, servername=extracheck, connect_to=ip,
696
697
698
                                 forceIPv4=opts.forceIPv4, forceIPv6=opts.forceIPv6,
                                 insecure=opts.insecure, verbose=opts.verbose, debug=opts.debug,
                                 multistreams=opts.multistreams)
699
700
701
702
703
    except TimeoutError:
        error("timeout")
    except ConnectionRefusedError:
        error("Connection to server refused")
    except ValueError:
704
        error("\"%s\" not a name or an IP address" % url)
705
    except socket.gaierror:
706
        error("Could not resolve \"%s\"" % url)
707
708
709
710
711
712
713
714
715
    except homer.ConnectionDOTException as e:
        if not monitoring:
            print(e, file=sys.stderr)
            err = "Could not connect to \"%s\"" % url
            if opts.connectTo is not None:
                err += " on %s" % opts.connectTo
            error(err)
        else:
            error(e)
Alexandre's avatar
Alexandre committed
716
    except (homer.ConnectionException, homer.DOHException) as e:
717
        error(e)
Alexandre's avatar
Alexandre committed
718
    if conn.dot and not conn.success:
719
        ok = False
Alexandre's avatar
Alexandre committed
720
        continue
Alexandre's avatar
Alexandre committed
721
722
723
    if opts.ifile is not None:
        input = open(opts.ifile)
    if not opts.check:
Alexandre's avatar
Alexandre committed
724
        ok = run_default(name, conn, opts)
725
    else:
726
        ok = run_check(conn) and ok # need to run run_check first
Alexandre's avatar
Alexandre committed
727
    if opts.ifile is not None:
728
729
        input.close()
    conn.end()
730

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
731
if ok:
Alexandre's avatar
Alexandre committed
732
    if opts.check or opts.pipelining:
Alexandre's avatar
Alexandre committed
733
        print('OK')
734
    sys.exit(0)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
735
else:
736
    print('KO')
737
    sys.exit(1)