homer.py 26.2 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
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
13

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

    # 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
27
28
import homer

29
# Values that can be changed from the command line
Alexandre's avatar
Alexandre committed
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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
58

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

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

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

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

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

134
def print_result(connection, request, prefix=None, display_err=True):
135
136
137
138
139
140
    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):
141
142
143
144
        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)
145
    else:
146
147
148
149
150
151
152
        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)
153
            else:
154
155
156
157
158
               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)
159

Alexandre's avatar
Alexandre committed
160
161
162
163
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

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
234
    ok = True
235
    for request_pack in requests:
Alexandre's avatar
Alexandre committed
236
237
238
239
240
241
242
243
244
        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:
245
            print(test_name)
Alexandre's avatar
Alexandre committed
246
        if connection.dot:
Alexandre's avatar
Alexandre committed
247
248
            bundle = request
        else:
249
250
251
            handle = connection.curl_handle
            handle.prepare(handle, connection, request)
            bundle = handle
Alexandre's avatar
Alexandre committed
252

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

261
262
        if level >= opts.mandatory_level:
            ok = request.check_response(connection.debug)
Alexandre's avatar
Alexandre committed
263
        print_check_result(test_name, ok, verbose=connection.verbose)
264
265
266
        print_result(connection, request, prefix=test_name, display_err=not ok)
        if not ok:
            break
Alexandre's avatar
Alexandre committed
267
    return ok
268

Alexandre's avatar
Alexandre committed
269
def check_truncated_query(connection, opts, level=homer.mandatory_levels["nicetohave"]):
Alexandre's avatar
Alexandre committed
270
271
272
273
    # 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
274
275
276
277
278

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

279
    ok = True
Alexandre's avatar
Alexandre committed
280

281
    test_name = 'Test truncated data'
Alexandre's avatar
Alexandre committed
282
283
284
285
286
287
288
289
290
291

    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:
292
        print(test_name)
Alexandre's avatar
Alexandre committed
293
    if connection.dot:
294
        bundle = request
295
    else:
296
297
298
        handle = connection.curl_handle
        handle.prepare(handle, connection, request)
        bundle = handle
Alexandre's avatar
Alexandre committed
299

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

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

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

Alexandre's avatar
Alexandre committed
332
    return ok
333
334

def run_check(connection):
Alexandre's avatar
Alexandre committed
335
336
337
338
339
    ok = True
    if connection.dot:
        ok = check_dot_two_requests(connection, opts)
    else:
        ok = check_doh_methods(connection, opts)
Alexandre's avatar
Alexandre committed
340
    if not ok and opts.mandatory_level >= homer.mandatory_levels["nicetohave"]:
341
        return False
Alexandre's avatar
Alexandre committed
342

Alexandre's avatar
Alexandre committed
343
344
345
    # TODO we miss the tests of pipelining and out-of-order for DoT and
    # multistreams for DoH

Alexandre's avatar
Alexandre committed
346
347
348
349
350
    # Test that different Header values are not breaking anything
    if not connection.dot:
        # The DoH server is right to reject these (Example: 'HTTP
        # error 415: only Content-Type: application/dns-message is
        # supported')
Alexandre's avatar
Alexandre committed
351
352
        ok = check_doh_header(connection, opts, level=homer.mandatory_levels["nocrash"], accept="text/html") and ok
        ok = check_doh_header(connection, opts, level=homer.mandatory_levels["nocrash"], content_type="text/html") and ok
Alexandre's avatar
Alexandre committed
353
354

    # test if a truncated query breaks anything
Alexandre's avatar
Alexandre committed
355
    ok = check_truncated_query(connection, opts, level=homer.mandatory_levels["nocrash"]) and ok
Alexandre's avatar
Alexandre committed
356
357

    return ok
358

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

367
def parse_opts(opts):
368
    name = None
369
370
    rtype = opts.rtype

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

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

506
    return (url, name)
507

Alexandre's avatar
Alexandre committed
508
509
def run_default(name, connection, opts):
    ok = True
510
    start = time.time()
511

Alexandre's avatar
Alexandre committed
512
513
    if opts.multistreams:
        connection.init_multi()
514

Alexandre's avatar
Alexandre committed
515
516
517
    for i in range (0, opts.tests):
        if opts.tests > 1 and (opts.verbose or opts.display_results):
            print("\nTest %i" % i)
518

Alexandre's avatar
Alexandre committed
519
520
        if opts.ifile is not None:
            name, opts.rtype = get_next_domain(input)
521
522
523
524
525
526
527
528
529

        if connection.dot:
            request = homer.RequestDOT(name, qtype=opts.rtype, use_edns=opts.edns,
                     want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)
        else:
            request = homer.RequestDOH(name, qtype=opts.rtype, use_edns=opts.edns,
                     want_dnssec=opts.dnssec, no_ecs=opts.no_ecs)

        request.to_wire()
Alexandre's avatar
Alexandre committed
530
        request.i = i
531

Alexandre's avatar
Alexandre committed
532
533
534
        if not opts.dot:
            request.head = opts.head
            request.post = opts.post
535

Alexandre's avatar
Alexandre committed
536
537
538
        if not opts.pipelining:
            try:
                connection.do_test(request, synchronous = not opts.multistreams)
Alexandre's avatar
Alexandre committed
539
            except (OpenSSL.SSL.Error, homer.DOHException) as e:
Alexandre's avatar
Alexandre committed
540
541
542
543
                ok = False
                error(e)
                break
            if not opts.multistreams:
544
545
                ok = request.success
                print_result(connection, request)
Alexandre's avatar
Alexandre committed
546
547
548
549
550
551
            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)
552

Alexandre's avatar
Alexandre committed
553
    if opts.multistreams:
554
        connection.perform_multi(opts.show_time, display_results=opts.display_results)
555

Alexandre's avatar
Alexandre committed
556
    if opts.dot and opts.pipelining:
557
        print()
Alexandre's avatar
Alexandre committed
558
559
560
        done = 0
        current = connection.pipelining_init_pending(opts.max_in_flight)
        while done < opts.tests:
Alexandre's avatar
Alexandre committed
561
            if time.time() > start + homer.MAX_DURATION: # if we send thousands of requests
Alexandre's avatar
Alexandre committed
562
563
564
565
566
567
568
569
                                                   # 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
570
            id = connection.read_result(connection, connection.pending, display_results=opts.display_results)
Alexandre's avatar
Alexandre committed
571
            if id is None: # Probably a timeout
Alexandre's avatar
Alexandre committed
572
                time.sleep(homer.SLEEP_TIMEOUT)
Alexandre's avatar
Alexandre committed
573
574
575
576
577
578
579
580
                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
581

582
583
584
585
586
    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 = ""
587
    if not opts.check or opts.verbose:
588
589
590
591
592
593
        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
594
595
    return ok

596
# Main program
597
url, name = parse_opts(opts)
598

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

Alexandre's avatar
Alexandre committed
628
629
630
631
632
# print number of IPs found
if opts.verbose and opts.check:
    print("Checking \"%s\" ..." % url)
    print("%d IP found : %s" % (len(ip_set), ', '.join(ip_set)))

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
633
ok = True
Alexandre's avatar
Alexandre committed
634
i = 1 # ip counter
Alexandre's avatar
Alexandre committed
635
for ip in ip_set:
Alexandre's avatar
Alexandre committed
636
637
    if opts.dot and opts.vhostname is not None:
        extracheck = opts.vhostname
638
639
    else:
        extracheck = None
Alexandre's avatar
Alexandre committed
640
    if opts.verbose and opts.check and ip:
Alexandre's avatar
Alexandre committed
641
        print("(%d/%d) checking IP : %s" % (i, len(ip_set), ip))
642
    try:
Alexandre's avatar
Alexandre committed
643
        if opts.dot:
Alexandre's avatar
Alexandre committed
644
            conn = homer.ConnectionDOT(url, servername=extracheck, connect_to=ip,
645
646
647
                                 forceIPv4=opts.forceIPv4, forceIPv6=opts.forceIPv6,
                                 insecure=opts.insecure, verbose=opts.verbose, debug=opts.debug,
                                 sni=opts.sni, key=opts.key, pipelining=opts.pipelining)
648
        else:
Alexandre's avatar
Alexandre committed
649
            conn = homer.ConnectionDOH(url, servername=extracheck, connect_to=ip,
650
651
652
                                 forceIPv4=opts.forceIPv4, forceIPv6=opts.forceIPv6,
                                 insecure=opts.insecure, verbose=opts.verbose, debug=opts.debug,
                                 multistreams=opts.multistreams)
653
654
655
656
657
    except TimeoutError:
        error("timeout")
    except ConnectionRefusedError:
        error("Connection to server refused")
    except ValueError:
658
        error("\"%s\" not a name or an IP address" % url)
659
    except socket.gaierror:
660
        error("Could not resolve \"%s\"" % url)
661
    except homer.ConnectionDOTException as e:
662
663
664
665
        print(e, file=sys.stderr)
        err = "Could not connect to \"%s\"" % url
        if opts.connectTo is not None:
            err += " on %s" % opts.connectTo
Alexandre's avatar
Alexandre committed
666
667
        elif ip is not None:
            err += " on %s" % ip
668
        error(err)
Alexandre's avatar
Alexandre committed
669
    except (homer.ConnectionException, homer.DOHException) as e:
670
        error(e)
Alexandre's avatar
Alexandre committed
671
    if conn.dot and not conn.success:
672
        ok = False
Alexandre's avatar
Alexandre committed
673
        continue
Alexandre's avatar
Alexandre committed
674
675
676
    if opts.ifile is not None:
        input = open(opts.ifile)
    if not opts.check:
Alexandre's avatar
Alexandre committed
677
        ok = run_default(name, conn, opts)
678
    else:
679
        ok = run_check(conn) and ok # need to run run_check first
Alexandre's avatar
Alexandre committed
680
    if opts.ifile is not None:
681
682
        input.close()
    conn.end()
683

Alexandre's avatar
Alexandre committed
684
685
    i += 1

Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
686
if ok:
Alexandre's avatar
Alexandre committed
687
    if opts.check or opts.pipelining:
Alexandre's avatar
Alexandre committed
688
        print('OK')
689
    sys.exit(0)
Stephane Bortzmeyer's avatar
Stephane Bortzmeyer committed
690
else:
691
    print('KO')
692
    sys.exit(1)