homer.py 25.8 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
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
62

63
def error(msg=None, exit=True):
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
64
65
    if msg is None:
        msg = "Unknown error"
66
67
68
69
70
    print(msg, file=sys.stderr)
    if opts.check:
        print('KO')
    if exit:
        sys.exit(1)
Alexandre's avatar
Alexandre committed
71

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
72
73
74
def usage(msg=None):
    if msg:
        print(msg,file=sys.stderr)
Alexandre's avatar
Alexandre committed
75
76
77
    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)
78
79
80
81
82
    -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
83
    -r --repeat <N>     Perform N times the query. If used with -f, read up to
84
                        <N> lines of the <file>
Alexandre's avatar
Alexandre committed
85
86
87
    -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
88
89
90
                        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
91
92
93
94
95
    --mandatory-level <level>
                        Define the <level> of test to perform (only with
                        --check)
                        Available <level> : legal, necessary, nicetohave
    --no-display-results
96
                        Disable output of DNS response
Alexandre's avatar
Alexandre committed
97
98
99
100
    --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
101
    -V --vhost <vhost>  Use a specific virtual host
Alexandre's avatar
Alexandre committed
102
103
    -h --help           Print this message

104
105
106
107
108
109
110
111
112
113
114
  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
115
116
117
    --pipelining        Pipeline the requests, needs an input file with -f
    --max-in-flight <M> Maximum number of concurrent requests in parallel (only
                        with --pipelining)
118

Alexandre's avatar
Alexandre committed
119
120
121
122
123
    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)
124
    print("See the README.md for more details.", file=sys.stderr)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
125

Alexandre's avatar
Alexandre committed
126
127
128
129
def get_next_domain(input_file):
    name, rtype = 'framagit.org', 'AAAA'
    line = input_file.readline()
    if line[:-1] == "":
Alexandre's avatar
Alexandre committed
130
        error("Not enough data in %s for the %i tests" % (opts.ifile, opts.tests))
Alexandre's avatar
Alexandre committed
131
132
133
134
135
136
137
    if line.find(' ') == -1:
        name = line[:-1]
        rtype = 'AAAA'
    else:
        (name, rtype) = line.split()
    return name, rtype

138
def print_result(connection, request, prefix=None, display_err=True):
139
140
141
142
143
144
    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):
145
146
147
148
        if not opts.dot and opts.show_time:
            connection.print_time(connection.curl_handle)
        if opts.display_results and (not opts.check or opts.verbose):
            print(msg)
149
    else:
150
151
152
153
154
155
156
        if display_err:
            if opts.check:
                print(connection.connect_to, end=': ', file=sys.stderr)
            if prefix:
                print(prefix, end=': ', file=sys.stderr)
            if dot:
                print("Error: %s" % msg, file=sys.stderr)
157
            else:
158
159
160
161
162
               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)
163

Alexandre's avatar
Alexandre committed
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237

def print_check_result(test_name, ok, verbose=True):
    if verbose:
        print(test_name, end=' : ')
        if ok:
            print('OK')
        else:
            print('KO')

def check_dot_two_requests(connection, opts):
    # not using a DoT connection -> exit the test
    if not connection.dot:
        return True

    r1 = homer.RequestDOT('framagit.org', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
    r2 = homer.RequestDOT('afnic.fr', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)

    requests = []
    requests.append(('Test 1', r1, homer.mandatory_levels["legal"]))
    # RFC 7858 section 3.3, SHOULD accept several requests on one connection.
    requests.append(('Test 2', r2, homer.mandatory_levels["necessary"]))

    return do_check(connection, requests, opts)

def check_doh_methods(connection, opts):
    # not using a DoH connection -> exit the test
    if connection.dot:
        return True

    r1 = homer.RequestDOH('framagit.org', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
    r2 = homer.RequestDOH('afnic.fr', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
    r2.post = True
    r3 = homer.RequestDOH('www.rfc-editor.org', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
    r3.head = True

    requests = []
    requests.append(('Test GET', r1, homer.mandatory_levels["legal"])) # RFC 8484, section 4.1
    requests.append(('Test POST', r2, homer.mandatory_levels["legal"])) # RFC 8484, section 4.1
    # HEAD method is not mentioned in RFC 8484 (see section 4.1), so just "nice to have".
    requests.append(('Test HEAD', r3, homer.mandatory_levels["nicetohave"]))

    return do_check(connection, requests, opts)

def check_doh_header(connection, opts, level=homer.mandatory_levels["nicetohave"],
        accept="application/dns-message", content_type="application/dns-message"):
    # 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

    # not using a DoH connection -> exit the test
    if connection.dot:
        return True

    header = ["Accept: %s" % accept, "Content-type: %s" % content_type]
    test_name = "Test Header MIME: %s " % ", ".join(h for h in header)
    r1 = homer.RequestDOH('curl.haxx.se', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
    r1.post = True

    requests = []
    requests.append((test_name, r1, level))

    handle = connection.curl_handle
    handle.setopt(pycurl.HTTPHEADER, header)

    ok = do_check(connection, requests, opts)

    default_accept = "application/dns-message"
    default_ct = "application/dns-message"
    default_header = ["Accept: %s" % default_accept, "Content-type: %s" % default_ct]
    handle.setopt(pycurl.HTTPHEADER, default_header)

    return ok

def do_check(connection, requests, opts):
Alexandre's avatar
Alexandre committed
238
    ok = True
239
    for request_pack in requests:
Alexandre's avatar
Alexandre committed
240
241
242
243
244
245
246
247
248
        test_name, request, level = request_pack

        # the test level is too small, therefore shouldn't be run
        if level < opts.mandatory_level:
            continue

        request.to_wire()

        if connection.debug:
249
            print(test_name)
Alexandre's avatar
Alexandre committed
250
        if connection.dot:
Alexandre's avatar
Alexandre committed
251
252
            bundle = request
        else:
253
254
255
            handle = connection.curl_handle
            handle.prepare(handle, connection, request)
            bundle = handle
Alexandre's avatar
Alexandre committed
256

Alexandre's avatar
Alexandre committed
257
258
259
        try:
            connection.send_and_receive(bundle)
        except (homer.ConnectionException, homer.DOHException) as e:
Alexandre's avatar
Alexandre committed
260
261
262
263
264
            print(e, file=sys.stderr)
            ok = False
            print_check_result(test_name, ok, verbose=connection.verbose)
            continue

265
266
267
        if level >= opts.mandatory_level:
            ok = request.check_response(connection.debug)
        print_result(connection, request, prefix=test_name, display_err=not ok)
Alexandre's avatar
Alexandre committed
268
        if connection.verbose:
Alexandre's avatar
Alexandre committed
269
            print_check_result(test_name, ok, verbose=connection.verbose)
270
271
        if not ok:
            break
Alexandre's avatar
Alexandre committed
272
    return ok
273

Alexandre's avatar
Alexandre committed
274
def check_truncated_query(connection, opts, level=homer.mandatory_levels["nicetohave"]):
Alexandre's avatar
Alexandre committed
275
276
277
278
    # 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
Alexandre's avatar
Alexandre committed
279
280
281
282
283

    # the test level is too small, therefore shouldn't be run
    if level < opts.mandatory_level:
       return True

284
    ok = True
Alexandre's avatar
Alexandre committed
285

286
    test_name = 'Test truncated data'
Alexandre's avatar
Alexandre committed
287
288
289
290
291
292
293
294
295
296

    if connection.dot:
        request = homer.RequestDOT('example.com', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
    else:
        request = homer.RequestDOH('example.com', qtype=opts.rtype, use_edns=opts.edns, want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
        request.post = True

    request.trunc_data()

    if connection.debug:
297
        print(test_name)
Alexandre's avatar
Alexandre committed
298
    if connection.dot:
299
        bundle = request
300
    else:
301
302
303
        handle = connection.curl_handle
        handle.prepare(handle, connection, request)
        bundle = handle
Alexandre's avatar
Alexandre committed
304

305
    try:
306
        # 8.8.8.8 replies FORMERR but most DoT servers violently shut down the connection (which is legal)
Alexandre's avatar
Alexandre committed
307
        connection.send_and_receive(bundle, dump=connection.debug)
308
    except OpenSSL.SSL.ZeroReturnError: # This is acceptable
Alexandre's avatar
Alexandre committed
309
        return True
310
311
312
313
    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
Alexandre's avatar
Alexandre committed
314
        return True
Alexandre's avatar
Alexandre committed
315
316
317
    except homer.DOHException as e:
        print(e, file=sys.stderr)
        return False
Alexandre's avatar
Alexandre committed
318

Alexandre's avatar
Alexandre committed
319
320
    if request.check_response(connection.debug): # FORMERR is expected
        if connection.dot:
Alexandre's avatar
Alexandre committed
321
322
323
            ok = request.rcode == dns.rcode.FORMERR
        else:
            ok = (request.response.rcode() == dns.rcode.FORMERR)
324
    else:
Alexandre's avatar
Alexandre committed
325
        if connection.dot:
Alexandre's avatar
Alexandre committed
326
            ok = False
Alexandre's avatar
Alexandre committed
327
328
329
330
331
        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
332
            ok = (request.rcode >= 400 and request.rcode < 500)
Alexandre's avatar
Alexandre committed
333

Alexandre's avatar
Alexandre committed
334
    print_result(connection, request, prefix=test_name, display_err=not ok)
Alexandre's avatar
Alexandre committed
335
    print_check_result(test_name, ok, verbose=connection.verbose)
336

Alexandre's avatar
Alexandre committed
337
    return ok
338
339

def run_check(connection):
Alexandre's avatar
Alexandre committed
340
341
342
343
344
345
    ok = True
    if connection.dot:
        ok = check_dot_two_requests(connection, opts)
    else:
        ok = check_doh_methods(connection, opts)
    if not ok:
346
        return False
Alexandre's avatar
Alexandre committed
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361

    # Test that different Header values are not breaking anything
    # this can be added in a specific level 'donotbreak'
    if not connection.dot:
        # The DoH server is right to reject these (Example: 'HTTP
        # error 415: only Content-Type: application/dns-message is
        # supported')
        ok = check_doh_header(connection, opts, level=10, accept="text/html") and ok
        ok = check_doh_header(connection, opts, level=10, content_type="text/html") and ok

    # test if a truncated query breaks anything
    # again iwbn to have a level such as 'donotbreak' for it
    ok = check_truncated_query(connection, opts, level=30) and ok

    return ok
362

Alexandre's avatar
Alexandre committed
363
def resolved_ips(host, port, family, dot=False):
364
365
366
    try:
        addr_list = socket.getaddrinfo(host, port, family)
    except socket.gaierror:
367
        error("Could not resolve \"%s\"" % host)
368
369
    ip_set = { addr[4][0] for addr in addr_list }
    return ip_set
Alexandre's avatar
Alexandre committed
370

371
def parse_opts(opts):
372
    name = None
373
374
    rtype = opts.rtype

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

Alexandre's avatar
Alexandre committed
457
    if opts.delay is not None and opts.multistreams:
Alexandre's avatar
Alexandre committed
458
        error("--delay makes no sense with multistreams")
Alexandre's avatar
Alexandre committed
459
    if opts.tests <= 1 and opts.delay is not None:
460
        error("--delay makes no sense if there is no repetition")
Alexandre's avatar
Alexandre committed
461
    if not opts.dot and opts.pipelining:
462
        error("Pipelining is only accepted for DoT")
463
        sys.exit(1)
Alexandre's avatar
Alexandre committed
464
    if opts.dot and (opts.post or opts.head):
465
        error("POST or HEAD makes non sense for DoT")
466
        sys.exit(1)    
Alexandre's avatar
Alexandre committed
467
    if opts.post and opts.head:
468
        error("POST or HEAD but not both")
469
        sys.exit(1)
Alexandre's avatar
Alexandre committed
470
    if opts.pipelining and opts.ifile is None:
471
        error("Pipelining requires an input file")
472
        sys.exit(1)
Alexandre's avatar
Alexandre committed
473
    if opts.check and opts.multistreams:
474
        error("--check and --multistreams are not compatible")
Alexandre's avatar
Alexandre committed
475
        sys.exit(1)
Alexandre's avatar
Alexandre committed
476
    if opts.dot and opts.multistreams:
477
        error("Multi-streams makes no sense for DoT")
478
        sys.exit(1)
Alexandre's avatar
Alexandre committed
479
    if opts.multistreams and opts.ifile is None:
480
        error("Multi-streams requires an input file")
481
        sys.exit(1)
Alexandre's avatar
Alexandre committed
482
    if opts.show_time and opts.dot:
483
        error("--time cannot be used with --dot")
484
        sys.exit(1)
Alexandre's avatar
Alexandre committed
485
    if not opts.edns and not opts.no_ecs:
486
        error("ECS requires EDNS")
487
        sys.exit(1)
Alexandre's avatar
Alexandre committed
488
    if opts.mandatory_level is not None and \
Alexandre's avatar
Alexandre committed
489
       opts.mandatory_level not in homer.mandatory_levels.keys():
Alexandre's avatar
Alexandre committed
490
        error("Unknown mandatory level \"%s\"" % opts.mandatory_level)
491
        sys.exit(1)
Alexandre's avatar
Alexandre committed
492
    if opts.mandatory_level is not None and not opts.check:
493
        error("--mandatory-level only makes sense with --check")
494
        sys.exit(1)
Alexandre's avatar
Alexandre committed
495
496
    if opts.mandatory_level is None:
        opts.mandatory_level = "necessary"
Alexandre's avatar
Alexandre committed
497
    opts.mandatory_level = homer.mandatory_levels[opts.mandatory_level]
Alexandre's avatar
Alexandre committed
498
    if opts.ifile is None and (len(args) != 2 and len(args) != 3):
499
        error("Wrong number of arguments")
500
        sys.exit(1)
Alexandre's avatar
Alexandre committed
501
    if opts.ifile is not None and len(args) != 1:
502
        error("Wrong number of arguments (if --file is used, do not indicate the domain name)")
503
504
        sys.exit(1)
    url = args[0]
Alexandre's avatar
Alexandre committed
505
    if opts.ifile is None:
506
507
        name = args[1]
        if len(args) == 3:
508
            opts.rtype = args[2]
509

510
    return (url, name)
511

Alexandre's avatar
Alexandre committed
512
513
def run_default(name, connection, opts):
    ok = True
514
    start = time.time()
Alexandre's avatar
Alexandre committed
515
516
517
518
519
520
521
    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
522
        request = homer.create_request(name, qtype=opts.rtype, use_edns=opts.edns,
Alexandre's avatar
Alexandre committed
523
524
525
526
527
528
529
530
                     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
531
            except (OpenSSL.SSL.Error, homer.DOHException) as e:
Alexandre's avatar
Alexandre committed
532
533
534
535
                ok = False
                error(e)
                break
            if not opts.multistreams:
536
537
                ok = request.success
                print_result(connection, request)
Alexandre's avatar
Alexandre committed
538
539
540
541
542
543
544
            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:
545
        connection.perform_multi(opts.show_time, display_results=opts.display_results)
Alexandre's avatar
Alexandre committed
546
547
548
549
550
    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
551
            if time.time() > start + homer.MAX_DURATION: # if we send thousands of requests
Alexandre's avatar
Alexandre committed
552
553
554
555
556
557
558
559
                                                   # 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
560
            id = connection.read_result(connection, connection.pending, display_results=opts.display_results)
Alexandre's avatar
Alexandre committed
561
            if id is None: # Probably a timeout
Alexandre's avatar
Alexandre committed
562
                time.sleep(homer.SLEEP_TIMEOUT)
Alexandre's avatar
Alexandre committed
563
564
565
566
567
568
569
570
                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
571
572
573
574
575
    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 = ""
576
    if not opts.check or opts.verbose:
577
578
579
580
581
582
        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
583
584
    return ok

585
586
587
# Main program
me = os.path.basename(sys.argv[0])

588
url, name = parse_opts(opts)
589

590
591
# retrieve all ips when using --check
# not necessary if connectTo is already defined
Alexandre's avatar
Alexandre committed
592
593
if not opts.check or opts.connectTo is not None:
    ip_set = {opts.connectTo, }
594
else:
Alexandre's avatar
Alexandre committed
595
    if opts.dot:
596
        port = homer.PORT_DOT
Alexandre's avatar
Alexandre committed
597
        if not homer.is_valid_hostname(url):
598
599
600
            error("DoT requires a host name or IP address, not \"%s\"" % url)
        netloc = url
    else:
601
        port = homer.PORT_DOH
Alexandre's avatar
Alexandre committed
602
        if not homer.is_valid_url(url):
603
            error("DoH requires a valid HTTPS URL, not \"%s\"" % url)
Alexandre's avatar
Alexandre committed
604
605
606
607
608
        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:
609
            error("The provided url \"%s\" could not be parsed" % url)
610
        netloc = url_parts.netloc
Alexandre's avatar
Alexandre committed
611
    if opts.forceIPv4:
612
        family = socket.AF_INET
Alexandre's avatar
Alexandre committed
613
    elif opts.forceIPv6:
614
615
616
        family = socket.AF_INET6
    else:
        family = 0
Alexandre's avatar
Alexandre committed
617
    ip_set = resolved_ips(netloc, port, family, opts.dot)
618

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
619
ok = True
Alexandre's avatar
Alexandre committed
620
for ip in ip_set:
Alexandre's avatar
Alexandre committed
621
622
    if opts.dot and opts.vhostname is not None:
        extracheck = opts.vhostname
623
624
    else:
        extracheck = None
Alexandre's avatar
Alexandre committed
625
    if opts.verbose and opts.check and ip:
626
        print("Checking \"%s\" on %s ..." % (url, ip))
627
    try:
Alexandre's avatar
Alexandre committed
628
        if opts.dot:
Alexandre's avatar
Alexandre committed
629
            conn = homer.ConnectionDOT(url, servername=extracheck, connect_to=ip,
630
631
632
                                 forceIPv4=opts.forceIPv4, forceIPv6=opts.forceIPv6,
                                 insecure=opts.insecure, verbose=opts.verbose, debug=opts.debug,
                                 sni=opts.sni, key=opts.key, pipelining=opts.pipelining)
633
        else:
Alexandre's avatar
Alexandre committed
634
            conn = homer.ConnectionDOH(url, servername=extracheck, connect_to=ip,
635
636
637
                                 forceIPv4=opts.forceIPv4, forceIPv6=opts.forceIPv6,
                                 insecure=opts.insecure, verbose=opts.verbose, debug=opts.debug,
                                 multistreams=opts.multistreams)
638
639
640
641
642
    except TimeoutError:
        error("timeout")
    except ConnectionRefusedError:
        error("Connection to server refused")
    except ValueError:
643
        error("\"%s\" not a name or an IP address" % url)
644
    except socket.gaierror:
645
        error("Could not resolve \"%s\"" % url)
646
    except homer.ConnectionDOTException as e:
647
648
649
650
651
        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)
Alexandre's avatar
Alexandre committed
652
    except (homer.ConnectionException, homer.DOHException) as e:
653
        error(e)
Alexandre's avatar
Alexandre committed
654
    if conn.dot and not conn.success:
655
        ok = False
Alexandre's avatar
Alexandre committed
656
        continue
Alexandre's avatar
Alexandre committed
657
658
659
    if opts.ifile is not None:
        input = open(opts.ifile)
    if not opts.check:
Alexandre's avatar
Alexandre committed
660
        ok = run_default(name, conn, opts)
661
    else:
662
        ok = run_check(conn) and ok # need to run run_check first
Alexandre's avatar
Alexandre committed
663
    if opts.ifile is not None:
664
665
        input.close()
    conn.end()
666

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
667
if ok:
Alexandre's avatar
Alexandre committed
668
    if opts.check or opts.pipelining:
Alexandre's avatar
Alexandre committed
669
        print('OK')
670
    sys.exit(0)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
671
else:
672
    print('KO')
673
    sys.exit(1)