Commit bf02bcbe authored by Stéphane Bortzmeyer's avatar Stéphane Bortzmeyer
Browse files

Merge branch 'option-check' into 'master'

Option --check

See merge request bortzmeyer/homer!4
parents a34ecfa4 e3d098e1
......@@ -51,6 +51,28 @@ Possible options, besides `--dot`:
* --insecure or -k: Does not check the certificate
* -4: Uses only IPv4
* -6: Uses only IPv6
* --check: Run a set of tests (see below)
### Check
The `--check` option allows to run several defined tests on a connection.
Homer displays `OK` on success and `KO` on failure.
The program stops after the first failed test.
```
% homer --check https://doh.bortzmeyer.fr framagit.org
OK
```
Each test is marked with a level of compliance. There are three
levels, "legal" (compliant with the strict requirments of the RFCs),
"necessary" (in a typical setup) and "nicetohave". The default level
is "necessary" but you can change it with option
`--mandatory-check`. For instance, sending a reply when the request
uses the HEAD method is "nicetohave" for a DoH server (the RFC does
not mandate it). The tests are always performed but are not fatal if
the choosen level is lower than the level of the test.
### Repetition of tests
......@@ -221,7 +243,7 @@ future, to remove servers that do not validate with DNSSEC.)
* `https://doh.libredns.gr/dns-query`
([Documentation](https://libredns.gr/); Also,
`https://doh.libredns.gr/ads` is a lying resolver, blocking ads and trackers)
* `https://dns.switch.ch/` ([Documentation](https://www.switch.ch/security/info/public-dns/))
* `https://dns.switch.ch/dns-query` ([Documentation](https://www.switch.ch/security/info/public-dns/))
* `https://nat64.tuxis.nl`
([Documentation](https://www.tuxis.nl/blog/public-doh-dot-dns64-nat64-service-20191021/);
NAT64, and no IPv4 address)
......
......@@ -49,6 +49,9 @@ delay = None
forceIPv4 = False
forceIPv6 = False
connectTo = None
check = False
mandatory_level = None
check_additional = True
# Monitoring plugin only:
host = None
path = None
......@@ -64,6 +67,13 @@ STATE_CRITICAL = 2
STATE_UNKNOWN = 3
STATE_DEPENDENT = 4
# For the check option
DOH_GET = 0
DOH_POST = 1
DOH_HEAD = 2
# Is the test mandatory?
mandatory_levels = {"legal": 30, "necessary": 20, "nicetohave": 10}
TIMEOUT_CONN = 2
def error(msg=None):
......@@ -73,7 +83,9 @@ def error(msg=None):
print("%s: %s" % (url, msg))
sys.exit(STATE_CRITICAL)
else:
print(msg,file=sys.stderr)
print(msg, file=sys.stderr)
if check:
print('KO')
sys.exit(1)
def usage(msg=None):
......@@ -185,8 +197,15 @@ class CustomException(Exception):
class Request:
def __init__(self, qname, qtype=rtype, use_edns=edns, want_dnssec=dnssec):
self.message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype), use_edns=use_edns, want_dnssec=want_dnssec)
self.message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype),
use_edns=use_edns, want_dnssec=want_dnssec)
self.message.flags |= dns.flags.AD # Ask for validation
self.ok = True
def trunc_data(self):
self.data = self.message.to_wire()
half = round(len(self.data) / 2)
self.data = self.data[:half]
def to_wire(self):
self.data = self.message.to_wire()
......@@ -206,7 +225,7 @@ class RequestDoH(Request):
self.head = False
def check_response(self):
ok = True
ok = self.ok
if self.rcode == 200:
if self.ctype != "application/dns-message":
self.response = "Content type of the response (\"%s\") invalid" % self.ctype
......@@ -235,6 +254,7 @@ class RequestDoH(Request):
self.response = "[No details]"
else:
self.response = self.response
self.ok = ok
return ok
......@@ -290,7 +310,7 @@ class ConnectionDoT(Connection):
forceIPv4=forceIPv4, forceIPv6=forceIPv6, dot=True,
verbose=verbose, insecure=insecure)
self.check_ip_address(self.server)
addrinfo_list = socket.getaddrinfo(server, 853, self.family)
addrinfo_list = socket.getaddrinfo(self.server, 853, self.family)
addrinfo_set = { (addrinfo[4], addrinfo[0]) for addrinfo in addrinfo_list }
signal.signal(signal.SIGALRM, timeout_connection)
connection_success = False
......@@ -370,12 +390,12 @@ class ConnectionDoT(Connection):
request.rcode = True
def send_and_receive(self, request):
request.to_wire()
self.send_data(request.data)
self.receive_data(request)
def do_test(self, qname, qtype=rtype):
request = RequestDoT(qname, qtype, want_dnssec=dnssec, use_edns=edns)
request.to_wire()
self.send_and_receive(request)
request.check_response()
return request
......@@ -407,7 +427,7 @@ class ConnectionDoH(Connection):
if self.connect is not None:
self.check_ip_address(self.connect)
self.curl.setopt(pycurl.CONNECT_TO, [f'::{self.repraddress}:443',])
self.curl.setopt(pycurl.HTTPHEADER, ["Content-type: application/dns-message"])
self.curl.setopt(pycurl.HTTPHEADER, ["Accept: application/dns-message", "Content-type: application/dns-message"])
def end(self):
self.curl.close()
......@@ -456,13 +476,19 @@ class ConnectionDoH(Connection):
def perform(self):
self.buffer = io.BytesIO()
self.set_opt(pycurl.WRITEDATA, self.buffer)
self.curl.perform()
try:
self.curl.perform()
except pycurl.error as e:
error(e.args[1])
def receive(self, request):
body = self.buffer.getvalue()
body_size = len(body)
http_code = self.curl.getinfo(pycurl.RESPONSE_CODE)
content_type = self.curl.getinfo(pycurl.CONTENT_TYPE)
try:
content_type = self.curl.getinfo(pycurl.CONTENT_TYPE)
except TypeError: # This is the exception we get if there is no Content-Type: (for intance in rsponse to HEAD requests)
content_type = None
request.response = body
request.response_size = body_size
request.rcode = http_code
......@@ -470,7 +496,6 @@ class ConnectionDoH(Connection):
self.buffer.close()
def send_and_receive(self, request):
request.to_wire()
self.prepare(request)
self.perform()
self.receive(request)
......@@ -479,6 +504,7 @@ class ConnectionDoH(Connection):
request = RequestDoH(qname, qtype, want_dnssec=dnssec, use_edns=edns)
request.head = head
request.post = post
request.to_wire()
self.send_and_receive(request)
request.check_response()
return request
......@@ -496,8 +522,8 @@ def get_next_domain(input_file):
(name, rtype) = line.split()
return name, rtype
def print_result(connection, request):
ok = True
def print_result(connection, request, prefix=None, display_err=True):
ok = request.ok
dot = connection.dot
server = connection.server
rcode = request.rcode
......@@ -505,7 +531,8 @@ def print_result(connection, request):
size = request.response_size
if (dot and rcode) or (not dot and rcode == 200):
if not monitoring:
print(msg)
if not check or verbose:
print(msg)
else:
if expect is not None and expect not in str(request.response):
ok = False
......@@ -518,14 +545,17 @@ def print_result(connection, request):
sys.exit(STATE_OK)
else:
if not monitoring:
if dot:
print("Error: %s" % msg, file=sys.stderr)
else:
try:
msg = msg.decode()
except (UnicodeDecodeError, AttributeError):
pass # Sometimes, msg can be binary, or Latin-1
print("HTTP error %i: %s" % (rcode, msg), file=sys.stderr)
if display_err:
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)
else:
if not dot:
print("%s HTTP error - %i: %s" % (server, rcode, msg))
......@@ -535,6 +565,129 @@ def print_result(connection, request):
ok = False
return ok
def create_request(dot=dot, trunc=False, **req_args):
if dot:
request = RequestDoT(**req_args)
else:
request = RequestDoH(**req_args)
if trunc:
request.trunc_data()
else:
request.to_wire()
return request
def create_requests_list(dot=dot, **req_args):
requests = []
if dot:
requests.append(('Test 1', create_request(dot=dot, **req_args),
mandatory_levels["legal"]))
requests.append(('Test 2', create_request(dot=dot, **req_args),
mandatory_levels["necessary"])) # RFC 7858,
# section 3.3, SHOULD accept several requests on one connection.
# TODO we miss the tests of pipelining and out-of-order.
else:
requests.append(('Test GET', create_request(**req_args), DOH_GET,
mandatory_levels["legal"])) # RFC 8484, section 4.1
requests.append(('Test POST', create_request(**req_args), DOH_POST,
mandatory_levels["legal"])) # RFC 8484, section 4.1
requests.append(('Test HEAD', create_request(**req_args), DOH_HEAD,
mandatory_levels["nicetohave"])) # HEAD
# method is not mentioned in RFC 8484 (see section 4.1), so
# just "nice to have".
return requests
def run_check_default(connection):
ok = True
req_args = { 'qname': name, 'qtype': rtype, 'use_edns': edns, 'want_dnssec': dnssec }
requests = create_requests_list(dot=dot, **req_args)
for request_pack in requests:
if dot:
test_name, request, mandatory = request_pack
else:
test_name, request, method, mandatory = request_pack
if verbose:
print(test_name)
if not dot:
if method == DOH_POST:
request.post = True
elif method == DOH_HEAD:
request.head = True
try:
connection.send_and_receive(request)
except CustomException as e:
ok = False
error(e)
break
request.check_response()
if not print_result(connection, request, prefix=test_name, display_err=False):
if mandatory >= mandatory_level:
print_result(connection, request, prefix=test_name, display_err=True)
ok = False
break
return ok
def run_check_mime(connection, accept="application/dns-message", content_type="application/dns-message"):
if dot:
return True
ok = True
header = [f"Accept: {accept}", f"Content-type: {content_type}"]
req_args = { 'qname': name, 'qtype': rtype, 'use_edns': edns, 'want_dnssec': dnssec }
request = create_request(**req_args)
connection.curl.setopt(pycurl.HTTPHEADER, header)
try:
connection.send_and_receive(request)
except CustomException as e:
ok = False
error(e)
request.check_response()
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}"]
connection.curl.setopt(pycurl.HTTPHEADER, default_header)
return ok
def run_check_trunc(connection):
ok = True
test_name = 'Test truncated data'
if verbose:
print(test_name)
req_args = { 'qname': name, 'qtype': rtype, 'use_edns': edns, 'want_dnssec': dnssec }
if dot:
request = create_request(dot=dot, trunc=True, **req_args)
else:
request = create_request(trunc=True, **req_args)
request.post = True
try:
# 8.8.8.8 replies FORMERR but most DoT servers violently shut down the connection (which is legal)
connection.send_and_receive(request)
except CustomException as e:
ok = False
error(e)
except OpenSSL.SSL.ZeroReturnError: # This is acceptable
return ok
request.check_response()
if print_result(connection, request, prefix=test_name, display_err=False): # The test must fail, or returns FORMERR.
ok = (request.rcode == dns.rcode.FORMERR)
return ok
def run_check_additionals(connection):
if not run_check_trunc(connection):
return False
# 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")
return True
def run_check(connection):
if not run_check_default(connection):
return False
if check_additional and not run_check_additionals(connection):
return False
return True
# Main program
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
......@@ -545,7 +698,8 @@ if not monitoring:
optlist, args = getopt.getopt (sys.argv[1:], "hvPkeV:r:f:d:t46",
["help", "verbose", "dot", "head",
"insecure", "POST", "vhost=",
"dnssec", "noedns","repeat=", "file=", "delay=", "v4only", "v6only"])
"dnssec", "noedns","repeat=", "file=", "delay=", "v4only", "v6only",
"check", "mandatory-level="])
for option, value in optlist:
if option == "--help" or option == "-h":
usage()
......@@ -580,6 +734,10 @@ if not monitoring:
forceIPv4 = True
elif option == "-6" or option == "v6only":
forceIPv6 = True
elif option == "--check":
check = True
elif option == "--mandatory-level":
mandatory_level = value
else:
error("Unknown option %s" % option)
except getopt.error as reason:
......@@ -593,6 +751,16 @@ if not monitoring:
if dot and (post or head):
usage("POST or HEAD makes non sense for DoT")
sys.exit(1)
if mandatory_level is not None and \
mandatory_level not in mandatory_levels.keys():
usage("Unknown mandatory level \"%s\"" % mandatory_level)
sys.exit(1)
if mandatory_level is not None and not check:
usage("--mandatory-level only makes sense with --check")
sys.exit(1)
if mandatory_level is None:
mandatory_level = "necessary"
mandatory_level = mandatory_levels[mandatory_level]
if ifile is None and (len(args) != 2 and len(args) != 3):
usage("Wrong number of arguments")
sys.exit(1)
......@@ -687,45 +855,53 @@ except TimeoutError:
error("timeout")
except ConnectionRefusedError:
error("Connection to server refused")
except ValueError:
error(f'"{url}" not a name or an IP address')
except socket.gaierror:
error(f'Could not resolve "{url}"')
except CustomException as e:
error(e)
if ifile is not None:
input = open(ifile)
for i in range (0, tests):
if tests > 1:
print("\nTest %i" % i)
if ifile is not None:
name, rtype = get_next_domain(input)
try:
request = conn.do_test(name, rtype)
except (OpenSSL.SSL.Error, CustomException) as e:
ok = False
error(e)
break
if not print_result(conn, request):
ok = False
if tests > 1 and i == 0:
start2 = time.time()
if delay is not None:
time.sleep(delay)
if not check:
for i in range (0, tests):
if tests > 1:
print("\nTest %i" % i)
if ifile is not None:
name, rtype = get_next_domain(input)
try:
request = conn.do_test(name, rtype)
except (OpenSSL.SSL.Error, CustomException) as e:
ok = False
error(e)
break
if not print_result(conn, request):
ok = False
if tests > 1 and i == 0:
start2 = time.time()
if delay is not None:
time.sleep(delay)
else:
ok = run_check(conn)
stop = time.time()
if tests > 1:
extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(tests-1))
else:
extra = ""
if not monitoring:
if not monitoring and (not check or verbose):
print("\nTotal elapsed time: %.2f seconds (%.2f ms/request %s)" % (stop-start, (stop-start)*1000/tests, extra))
if ifile is not None:
input.close()
conn.end()
if ok:
print('OK')
if not monitoring:
sys.exit(0)
else:
sys.exit(STATE_OK)
else:
print('KO')
if not monitoring:
sys.exit(1)
else:
sys.exit(STATE_CRITICAL)
......@@ -6,6 +6,7 @@ config:
- "doh: test specific to DoH"
- "monitoring: test using monitoring"
- "exception: test raising an exception"
- "check: test related to the compliance option --check"
tests:
- exe: './homer.py'
......@@ -39,6 +40,75 @@ tests:
stderr: ''
partstdout: '2a01:4f8:'
- exe: './homer.py'
markers:
- 'exception'
args:
- '--dot'
- 'https//doh.bortzmeyer.fr'
- 'framagit.org'
retcode: 1
partstderr: 'not a name or'
stdout: ''
###############################################################################
- exe: './homer.py'
name: '--check of a correct DoH'
markers:
- 'doh'
- 'check'
args:
- '--check'
- 'https://doh.bortzmeyer.fr/'
- 'ressources-pedagogiques.org'
retcode: 0
stderr: ''
stdout: "OK\n"
- exe: './homer.py'
name: '--check of a broken DoH'
markers:
- 'doh'
- 'check'
args:
- '--check'
- 'https://www.bortzmeyer.org/'
- 'ressources-pedagogiques.org'
retcode: 1
stderr: ''
stdout: "KO\n"
- exe: './homer.py'
name: '--check of a DoH with HEAD unimplemented'
markers:
- 'doh'
- 'check'
args:
- '--check'
- '--mandatory-level'
- 'nicetohave'
- 'https://doh.42l.fr/dns-query'
- 'ressources-pedagogiques.org'
retcode: 1
stderr: "Test HEAD: HTTP error 405: [No details]\n"
stdout: "KO\n"
- exe: './homer.py'
name: '--check of a correct DoT'
markers:
- 'dot'
- 'check'
args:
- '--check'
- '--dot'
- 'dot.bortzmeyer.fr'
- 'ressources-pedagogiques.org'
retcode: 0
stderr: ''
stdout: "OK\n"
###############################################################################
- exe: './homer.py'
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment