Commit 459f8d01 authored by Alexandre's avatar Alexandre
Browse files

Merge branch 'master' into streams

parents 1739a8a3 a58301ef
......@@ -51,6 +51,12 @@ Possible options, besides `--dot`:
* --insecure or -k: Does not check the certificate
* -4: Uses only IPv4
* -6: Uses only IPv6
* --dnssec: requests DNSSEC data (signatures)
* --noedns: no EDNS (default is to indicate EDNS support)
* --ecs: send ECS, my subnet to auth. servers (default is to refuse
it)
* --key KEYINBASE64: authentifies a DoT resolver with its public
key. Example: `homer.py --key "62lKu9HsDVbyiPenApnc4sfmSYTHOVfFgL3pyB+cBL4=" --dot 145.100.185.15 IN NS`
* --check: Run a set of tests (see below)
### Check
......@@ -133,6 +139,7 @@ monitoring plugins conventions:
* -P: uses the HTTP method POST
* -h: uses the HTTP method HEAD
* -i: insecure (do not check the certificate)
* -k: authenticated the DoT server with this public key
For Icinga, the following definition enables the plugin:
......@@ -165,7 +172,8 @@ object CheckCommand "dot_monitor" {
"-t" = "$dot_type$",
"-P" = "$dot_post$",
"-i" = "$dot_insecure$",
"-h" = "$dot_head$"
"-h" = "$dot_head$",
"-k" = "$dot_key$"
}
}
......
#!/bin/sh
domain=framagit.org
type=AAAA
if [ "$1" != "" ]; then
domain=$1
if [ "$2" != "" ]; then
type=$2
fi
fi
echo "DoT"
for server in $(cat dot-servers.txt); do
echo ""
echo $server
./homer.py --check --dot $server $domain $type
done
echo ""
echo "DoH"
for url in $(cat doh-servers.txt); do
echo ""
echo $url
./homer.py --check $url $domain $type
done
https://doh.powerdns.org/
https://doh.bortzmeyer.fr/
https://doh.42l.fr/dns-query
https://odvr.nic.cz/doh
https://dns.hostux.net/dns-query
https://ldn-fai.net/dns-query
https://dns.digitale-gesellschaft.ch/dns-query
https://doh.ffmuc.net
https://doh.libredns.gr/dns-query
https://dns.switch.ch/dns-query
https://nat64.tuxis.nl
https://cloudflare-dns.com/dns-query
https://dns.google/dns-query
https://doh.xfinity.com/dns-query
https://dns.adguard.com/dns-query
https://dns.quad9.net/dns-query
https://doh.opendns.com/dns-query
https://dns.nextdns.io/
https://doh.crypto.sx/dns-query
https://dns.rubyfish.cn/dns-query
https://doh.applied-privacy.net/query
https://dns.twnic.tw/dns-query
dot.bortzmeyer.fr
dns.digitale-gesellschaft.ch
dot.ffmuc.net
ns0.ldn-fai.net
dot.libredns.gr
dns.switch.ch
nat64.tuxis.net
anycast.censurfridns.dk
dns.google
one.one.one.one
dns9.quad9.net
dot.xfinity.com
dnsovertls.sinodun.com
getdnsapi.net
dns.cmrg.net
dot.securedns.eu
tls-dns-u.odvr.dns-oarc.net
dnsotls.lab.nic.cl
dns.rubyfish.cn
dns.adguard.com
......@@ -41,9 +41,12 @@ 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
......@@ -77,17 +80,19 @@ mandatory_levels = {"legal": 30, "necessary": 20, "nicetohave": 10}
TIMEOUT_CONN = 2
def error(msg=None):
def error(msg=None, exit=True):
if msg is None:
msg = "Unknown error"
if monitoring:
print("%s: %s" % (url, msg))
sys.exit(STATE_CRITICAL)
if exit:
sys.exit(STATE_CRITICAL)
else:
print(msg, file=sys.stderr)
if check:
print('KO')
sys.exit(1)
if exit:
sys.exit(1)
def usage(msg=None):
if msg:
......@@ -217,8 +222,13 @@ class CustomException(Exception):
class Request:
def __init__(self, qname, qtype=rtype, use_edns=edns, want_dnssec=dnssec):
if no_ecs:
opt = dns.edns.ECSOption(address='', srclen=0) # Disable ECS (RFC 7871, section 7.1.2)
options = [opt]
else:
options = None
self.message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype),
use_edns=use_edns, want_dnssec=want_dnssec)
use_edns=use_edns, want_dnssec=want_dnssec, options=options)
self.message.flags |= dns.flags.AD # Ask for validation
self.ok = True
......@@ -280,10 +290,10 @@ class RequestDoH(Request):
class Connection:
def __init__(self, server, servername=None, connect=None, forceIPv4=False, forceIPv6=False,
dot=False, verbose=verbose, insecure=insecure):
dot=dot, verbose=verbose, insecure=insecure):
if dot and not is_valid_hostname(server):
error("DoT requires a host name or IP address, not \"%s\"" % server)
if not dot and not is_valid_url(url):
if not dot and not is_valid_url(server):
error("DoH requires a valid HTTPS URL, not \"%s\"" % server)
if forceIPv4 and forceIPv6:
raise CustomException("Force IPv4 *or* IPv6 but not both")
......@@ -314,22 +324,30 @@ class ConnectionDoT(Connection):
Connection.__init__(self, server, servername=servername, connect=connect,
forceIPv4=forceIPv4, forceIPv6=forceIPv6, dot=True,
verbose=verbose, insecure=insecure)
if connect is not None:
addr = connect
else:
addr = self.server
self.family, self.repraddress = check_ip_address(self.server, dot=True)
addrinfo_list = socket.getaddrinfo(self.server, 853, self.family)
addrinfo_list = socket.getaddrinfo(addr, 853, self.family)
addrinfo_set = { (addrinfo[4], addrinfo[0]) for addrinfo in addrinfo_list }
signal.signal(signal.SIGALRM, timeout_connection)
connection_success = False
self.success = False
for addrinfo in addrinfo_set:
self.hasher = hashlib.sha256()
if self.connect(addrinfo[0], addrinfo[1]):
connection_success = True
self.success = True
break
if self.verbose:
if self.verbose and connect is None:
print("Trying another IP address")
if not connection_success:
if self.verbose:
if not self.success:
if self.verbose and connect is None:
print("No other IP address")
error(f'Could not connect to "{server}"')
if connect is None:
error(f'Could not connect to "{server}"')
else:
print(f'Could not connect to "{server}" on {connect}')
def connect(self, addr, sock_family):
signal.alarm(TIMEOUT_CONN)
......@@ -350,7 +368,8 @@ class ConnectionDoT(Connection):
OpenSSL.SSL.VERIFY_CLIENT_ONCE,
lambda conn, cert, errno, depth, preverify_ok: preverify_ok)
self.session = OpenSSL.SSL.Connection(self.context, self.sock)
self.session.set_tlsext_host_name(canonicalize(self.check).encode()) # Server Name Indication (SNI)
if sni:
self.session.set_tlsext_host_name(canonicalize(self.check).encode()) # Server Name Indication (SNI)
try:
self.session.connect((self.addr))
# TODO We may here have exceptions such as OpenSSL.SSL.ZeroReturnError
......@@ -359,23 +378,33 @@ class ConnectionDoT(Connection):
if self.verbose:
print("Timeout")
return False
self.cert = self.session.get_peer_certificate()
except OSError:
if self.verbose:
print("Cannot connect")
return False
# RFC 7858, section 4.2 and appendix A
self.cert = self.session.get_peer_certificate()
self.publickey = self.cert.get_pubkey()
if verbose or key is not None:
self.hasher.update(OpenSSL.crypto.dump_publickey(OpenSSL.crypto.FILETYPE_ASN1,
self.publickey))
self.digest = self.hasher.digest()
key_string = base64.standard_b64encode(self.digest).decode()
if verbose:
print("Certificate #%x for \"%s\", delivered by \"%s\"" % \
(self.cert.get_serial_number(),
self.cert.get_subject().commonName,
self.cert.get_issuer().commonName))
self.hasher.update(OpenSSL.crypto.dump_publickey(OpenSSL.crypto.FILETYPE_ASN1,
self.publickey))
self.digest = self.hasher.digest()
print("Public key is pin-sha256=\"%s\"" % \
base64.standard_b64encode(self.digest).decode())
key_string)
if not insecure:
valid = validate_hostname(self.check, self.cert)
if not valid:
error("Certificate error: \"%s\" is not in the certificate" % (self.check))
if key is None:
valid = validate_hostname(self.check, self.cert)
if not valid:
error("Certificate error: \"%s\" is not in the certificate" % (self.check))
else:
if key_string != key:
error("Key error: expected \"%s\", got \"%s\"" % (key, key_string))
signal.alarm(0)
return True
......@@ -758,6 +787,14 @@ def run_check(connection):
return False
return True
def resolved_ips(host, port, family, dot=dot):
try:
addr_list = socket.getaddrinfo(host, port, family)
except socket.gaierror:
error(f'Could not resolve "{url}"')
ip_set = { addr[4][0] for addr in addr_list }
return ip_set
# Main program
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
......@@ -768,7 +805,9 @@ if not monitoring:
optlist, args = getopt.getopt (sys.argv[1:], "hvPkeV:r:f:d:t46",
["help", "verbose", "dot", "head",
"insecure", "POST", "vhost=", "multistreams",
"dnssec", "noedns","repeat=", "file=", "delay=", "v4only", "v6only",
"dnssec", "noedns", "ecs", "repeat=", "file=", "delay=",
"key=", "nosni",
"v4only", "v6only",
"check", "mandatory-level="])
for option, value in optlist:
if option == "--help" or option == "-h":
......@@ -790,8 +829,15 @@ if not monitoring:
multistreams = True
elif option == "--dnssec":
dnssec = True
elif option == "--noedns":
elif option == "--nosni":
sni = False
elif option == "--noedns": # Warning: it will mean the
# resolver may send ECS
# information to the
# authoritative name servers.
edns = False
elif option == "--ecs":
no_ecs = False
elif option == "--repeat" or option == "-r":
tests = int(value)
if tests <= 1:
......@@ -802,6 +848,8 @@ if not monitoring:
error("--delay needs a value > 0")
elif option == "--file" or option == "-f":
ifile = value
elif option == "--key":
key = value
elif option == "-4" or option == "v4only":
forceIPv4 = True
elif option == "-6" or option == "v6only":
......@@ -833,6 +881,9 @@ if not monitoring:
# TODO: use multistreams with --check
usage("Multi-streams requires an input file")
sys.exit(1)
if not edns and not no_ecs:
usage("ECS requires EDNS")
sys.exit(1)
if mandatory_level is not None and \
mandatory_level not in mandatory_levels.keys():
usage("Unknown mandatory level \"%s\"" % mandatory_level)
......@@ -860,7 +911,7 @@ else: # Monitoring plugin
dot = (me == "check_dot")
name = None
try:
optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:e:Pih46")
optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:e:Pih46k:x")
for option, value in optlist:
if option == "-H":
host = value
......@@ -880,10 +931,14 @@ else: # Monitoring plugin
head = True
elif option == "-i":
insecure = True
elif option == "-x":
sni = False
elif option == "-4":
forceIPv4 = True
elif option == "-6":
forceIPv6 = True
elif option == "-k":
key = value
else:
# Should never occur, it is trapped by getopt
print("Unknown option %s" % option)
......@@ -923,72 +978,108 @@ else: # Monitoring plugin
path = path[1:]
url += path
# retrieve all ips when using --check
# not necessary if connectTo is already defined
# as it is the case with --monitoring
if not check or connectTo is not None:
ip_set = {connectTo, }
else:
if dot:
port = 853
if not is_valid_hostname(url):
error("DoT requires a host name or IP address, not \"%s\"" % url)
netloc = url
else:
port = 443
if not is_valid_url(url):
error("DoH requires a valid HTTPS URL, not \"%s\"" % url)
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:
error(f'The provided url "{url}" could not be parsed')
netloc = url_parts.netloc
if forceIPv4:
family = socket.AF_INET
elif forceIPv6:
family = socket.AF_INET6
else:
family = 0
ip_set = resolved_ips(netloc, port, family, dot)
ok = True
start = time.time()
try:
for connectTo in ip_set:
start = time.time()
if dot and vhostname is not None:
extracheck = vhostname
else:
extracheck = None
if dot:
conn = ConnectionDoT(url, servername=extracheck, connect=connectTo, verbose=verbose,
forceIPv4=forceIPv4, forceIPv6=forceIPv6,
insecure=insecure)
else:
conn = ConnectionDoH(url, servername=extracheck, connect=connectTo, verbose=verbose,
forceIPv4=forceIPv4, forceIPv6=forceIPv6,
multistreams=multistreams, insecure=insecure)
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)
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, synchronous = not multistreams)
except (OpenSSL.SSL.Error, CustomException) as e:
ok = False
error(e)
break
if not multistreams:
if not print_result(conn, request):
if verbose and check and connectTo:
print(f'Checking "{url}" on {connectTo} ...')
try:
if dot:
conn = ConnectionDoT(url, servername=extracheck, connect=connectTo, verbose=verbose,
forceIPv4=forceIPv4, forceIPv6=forceIPv6,
insecure=insecure)
else:
conn = ConnectionDoH(url, servername=extracheck, connect=connectTo, verbose=verbose,
forceIPv4=forceIPv4, forceIPv6=forceIPv6,
multistreams=multistreams, insecure=insecure)
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 conn.dot and not conn.success:
ok = False
continue
if ifile is not None:
input = open(ifile)
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, synchronous = not multistreams)
except (OpenSSL.SSL.Error, CustomException) as e:
ok = False
else: # We do multistreams
pending[i] = request # No result yet
if tests > 1 and i == 0:
start2 = time.time()
if delay is not None:
time.sleep(delay)
if multistreams:
conn.perform_multi()
print("")
result = read_results(conn, pending)
for j in result:
print("Return code %s: %s\n" % (result[j].rcode, result[j].response))
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 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()
error(e)
break
if not multistreams:
if not print_result(conn, request):
ok = False
else: # We do multistreams
pending[i] = request # No result yet
if tests > 1 and i == 0:
start2 = time.time()
if delay is not None:
time.sleep(delay)
if multistreams:
conn.perform_multi()
print("")
result = read_results(conn, pending)
for j in result:
print("Return code %s: %s\n" % (result[j].rcode, result[j].response))
else:
ok = run_check(conn) and ok # need to run run_check first
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 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:
......
---
config:
timeout: 2
timeout: 4
markers:
- "dot: test specific to DoT"
- "doh: test specific to DoH"
......@@ -8,6 +8,8 @@ config:
- "exception: test raising an exception"
- "streams: test multistreams with DoH"
- "check: test related to the compliance option --check"
- "forceIPv4: test using the option -4"
- "forceIPv6: test using the option -6"
tests:
- exe: './homer.py'
......@@ -57,8 +59,8 @@ tests:
- exe: './homer.py'
name: '--check of a correct DoH'
markers:
- 'doh'
- 'check'
- 'doh'
- 'check'
args:
- '--check'
- 'https://doh.bortzmeyer.fr/'
......@@ -70,8 +72,8 @@ tests:
- exe: './homer.py'
name: '--check of a broken DoH'
markers:
- 'doh'
- 'check'
- 'doh'
- 'check'
args:
- '--check'
- 'https://www.bortzmeyer.org/'
......@@ -83,8 +85,8 @@ tests:
- exe: './homer.py'
name: '--check of a DoH with HEAD unimplemented'
markers:
- 'doh'
- 'check'
- 'doh'
- 'check'
args:
- '--check'
- '--mandatory-level'
......@@ -98,8 +100,8 @@ tests:
- exe: './homer.py'
name: '--check of a correct DoT'
markers:
- 'dot'
- 'check'
- 'dot'
- 'check'
args:
- '--check'
- '--dot'
......@@ -109,6 +111,200 @@ tests:
stderr: ''
stdout: "OK\n"
- exe: './homer.py'
name: '[doh][check] Test that all the resolved IPs are tried, try a first IP'
markers:
- 'doh'
- 'check'
args:
- '-v'
- '--check'
- 'https://doh.bortzmeyer.fr'
- 'www.afnic.fr'
partstderr: "Connecting to hostname: 2001:41d0:302:2200::180"
- exe: './homer.py'
name: '[doh][check] Test that all the resolved IPs are tried, try another IP'
markers:
- 'doh'
- 'check'
args:
- '-v'
- '--check'
- 'https://doh.bortzmeyer.fr'
- 'curl.haxx.se'
partstderr: "Connecting to hostname: 193.70.85.11"
- exe: './homer.py'
name: '[dot][check] Test that all the resolved IPs are tried, try a first IP'
markers:
- 'dot'
- 'check'
args:
- '-v'
- '--check'
- '--dot'
- 'dot.bortzmeyer.fr'
- 'www.afnic.fr'
partstdout: "on 193.70.85.11 ...\nConnecting to ('193.70.85.11', 853) ..."
- exe: './homer.py'
name: '[doh][check] Test that all the resolved IPs are tried, try another IP'
markers:
- 'dot'
- 'check'
args:
- '-v'
- '--check'
- '--dot'
- 'dot.bortzmeyer.fr'
- 'www.afnic.fr'
partstdout: "on 2001:41d0:302:2200::180 ...\nConnecting to ('2001:41d0:302:2200::180', 853, 0, 0) ..."
- exe: './homer.py'
name: '[dot][check] Test all the IPs, force IPv4'
markers:
- 'dot'
- 'check'
- 'forceIPv4'
args:
- '-v'
- '-4'