Commit 91f5044a authored by Stephane Bortzmeyer's avatar Stephane Bortzmeyer
Browse files

One code for monitoring and interactive. Closes #2

parent 749902b2
#!/usr/bin/env python3
"""Monitoring plugin (Nagios-compatible) for watching a DoH resolver.
The monitoring plugin API is documented at
<https://www.monitoring-plugins.org/doc/guidelines.html>.
"""
# Do not touch
# https://www.monitoring-plugins.org/doc/guidelines.html#AEN78
STATE_OK = 0
STATE_WARNING = 1
STATE_CRITICAL = 2
STATE_UNKNOWN = 3
STATE_DEPENDENT = 4
# http://pycurl.io/docs/latest
import pycurl
# http://www.dnspython.org/
import dns.message
import io
import sys
import base64
import urllib.parse
import socket
import re
import getopt
host = None
vhostname = None
path = None
lookup = None
ltype = 'AAAA'
post = False
insecure = False
head = False
# TODO add an option: a string which is expected in the DNS response
try:
optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:Pih")
for option, value in optlist:
if option == "-H":
host = value
elif option == "-V":
vhostname = value
elif option == "-n":
lookup = value
elif option == "-t":
ltype = value
elif option == "-p":
path = value
elif option == "-P":
post = True
elif option == "-i":
insecure = True
elif option == "-h":
head = True
else:
# Should never occur, it is trapped by getopt
print("Unknown option %s" % option)
sys.exit(STATE_UNKNOWN)
except getopt.error as reason:
print("Option parsing problem %s" % reason)
sys.exit(STATE_UNKNOWN)
if len(args) > 0:
print("Too many arguments (\"%s\")" % args)
sys.exit(STATE_UNKNOWN)
if host is None or lookup is None:
print("Host and name to lookup are necessary")
sys.exit(STATE_UNKNOWN)
if post and head:
print("POST or HEAD but not both")
sys.exit(STATE_UNKNOWN)
if vhostname is not None:
url = "https://%s/" % vhostname # host is ignored in that case, which is a bit strange
else:
url = "https://%s/" % host
if path is not None:
url += path
try:
buffer = io.BytesIO()
c = pycurl.Curl()
message = dns.message.make_query(lookup, dns.rdatatype.from_text(ltype))
message.id = 0 # DoH requests that
if head:
c.setopt(pycurl.NOBODY, True)
if post:
c.setopt(c.URL, url)
data = message.to_wire()
c.setopt(pycurl.POST, True)
c.setopt(pycurl.POSTFIELDS, data)
else:
dns_req = base64.urlsafe_b64encode(message.to_wire()).decode('UTF8').rstrip('=')
c.setopt(c.URL, url + ("?dns=%s" % dns_req))
c.setopt(pycurl.HTTPHEADER, ["Content-type: application/dns-message"])
c.setopt(c.WRITEDATA, buffer)
if insecure:
c.setopt(pycurl.SSL_VERIFYPEER, False)
c.setopt(pycurl.SSL_VERIFYHOST, False)
# Does not work if pycurl was not compiled with nghttp2 (recent Debian
# packages are OK) https://github.com/pycurl/pycurl/issues/477
c.setopt(pycurl.HTTP_VERSION, pycurl.CURL_HTTP_VERSION_2)
try:
c.perform()
except Exception as e:
print("%s ERROR - %s" % (url, "Cannot connect: \"%s\"" % e))
sys.exit(STATE_CRITICAL)
rcode = c.getinfo(pycurl.RESPONSE_CODE)
c.close()
if rcode == 200:
if not head:
body = buffer.getvalue()
try:
response = dns.message.from_wire(body) # May be we should test the DNS response code as well?
except dns.name.BadLabelType as e:
print("%s ERROR - %s" % (url, "Not a DNS reply, is it a DoH server? \"%s\"" % e))
sys.exit(STATE_CRITICAL)
print("%s OK - %s" % (url, "No error for %s/%s, %i bytes received" % (lookup, ltype, sys.getsizeof(body))))
else:
print("%s OK - %s" % (url, "No error"))
sys.exit(STATE_OK)
else:
body = buffer.getvalue()
if len(body) == 0:
body = b"[No details]"
print("%s HTTP error - %i: %s" % (url, rcode, body.decode()))
sys.exit(STATE_CRITICAL)
except Exception as e:
print("%s UNKNOWN - %s" % (url, "Unknown internal error: \"%s\"" % e))
sys.exit(STATE_UNKNOWN)
......@@ -20,6 +20,7 @@ import time
import socket
import ctypes
import re
import os.path
# Values that can be changed from the command line
dot = False # DoH by default
......@@ -31,15 +32,31 @@ rtype = 'AAAA'
tests = 1 # Number of repeated tests
ifile = None # Input file
delay = None
# Monitoring plugin only:
host = None
vhostname = None
path = None
# TODO add an option: a string which is expected in the DNS response
# Do not change these
re_host = re.compile(r'^([0-9a-z][0-9a-z-\.]*)|([0-9:]+)|([0-9\.])$')
# For the monitoring plugin
STATE_OK = 0
STATE_WARNING = 1
STATE_CRITICAL = 2
STATE_UNKNOWN = 3
STATE_DEPENDENT = 4
def error(msg=None):
if msg is None:
msg = "Unknown error"
print(msg,file=sys.stderr)
sys.exit(1)
if monitoring:
print("%s: %s" % (url, msg))
sys.exit(STATE_CRITICAL)
else:
print(msg,file=sys.stderr)
sys.exit(1)
def usage(msg=None):
if msg:
......@@ -89,14 +106,14 @@ def validate_hostname(hostname, cert):
base = base.lower()
if base.endswith("\n"):
base = base[:-1]
if hostname == base: # TODO better canonicaliztion of IP addresses with the netaddr module
if hostname == base: # TODO better canonicalization of IP addresses with the netaddr module
return True
else:
pass # Ignore unknown alternative name types
return False
class Connection:
def __init__(self, server, dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
def __init__(self, server, servername=None, dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
if dot and not is_valid_hostname(server):
error("DoT requires a host name, not \"%s\"" % server)
if not dot and not is_valid_url(url):
......@@ -130,9 +147,13 @@ class Connection:
self.session.do_handshake()
self.cert = self.session.get_peer_certificate()
if not insecure:
valid = validate_hostname(server, self.cert)
if servername is not None:
check = servername
else:
check = server
valid = validate_hostname(check, self.cert)
if not valid:
error("Certificate error: \"%s\" is not in the certificate" % server)
error("Certificate error: \"%s\" is not in the certificate" % (check))
# TODO validate with SPKI?
else: # DoH
self.curl = pycurl.Curl()
......@@ -162,9 +183,11 @@ class Connection:
# Routine doing one actual test. Returns a tuple, first member is a
# result (boolean indicating success for DoT, HTTP status code for
# DoH), second member is a DNS message (or a string if there is an
# error).
# error), third member is the size of the DNS message (or None if no
# proper response).
def do_test(connection, qname, qtype=rtype):
message = dns.message.make_query(qname, dns.rdatatype.from_text(qtype))
size = None
if connection.dot:
# TODO Check what the Query ID is. Really random?
messagew = message.to_wire()
......@@ -174,7 +197,7 @@ def do_test(connection, qname, qtype=rtype):
received = int.from_bytes(buf, byteorder='big')
buf = connection.session.recv(received)
response = dns.message.from_wire(buf) # TODO check the Query ID
return (True, response)
return (True, response, received)
else: # DoH
message.id = 0 # DoH requests that
if connection.post:
......@@ -193,6 +216,7 @@ def do_test(connection, qname, qtype=rtype):
if not head:
body = buffer.getvalue()
try:
size = len(body)
response = dns.message.from_wire(body)
except dns.message.TrailingJunk: # Not DNS.
response = "ERROR Not proper DNS data \"%s\"" % body
......@@ -207,65 +231,124 @@ def do_test(connection, qname, qtype=rtype):
else:
response = body
buffer.close()
return (rcode, response)
return (rcode, response, size)
# Main program
name = None
message = None
try:
optlist, args = getopt.getopt (sys.argv[1:], "hvPker:f:d:t",
["help", "verbose", "dot", "head", "insecure", "POST", "repeat=", "file=", "delay="])
for option, value in optlist:
if option == "--help" or option == "-h":
usage()
sys.exit(0)
elif option == "--verbose" or option == "-v":
verbose = True
elif option == "--head" or option == "-e":
head = True
elif option == "--dot" or option == "-t":
dot = True
elif option == "--insecure" or option == "-k":
insecure = True
elif option == "--POST" or option == "-P":
post = True
elif option == "--repeat" or option == "-r":
tests = int(value)
if tests <= 1:
error("--repeat needs a value > 1")
elif option == "--delay" or option == "-d":
delay = float(value)
if delay <= 0:
error("--delay needs a value > 0")
elif option == "--file" or option == "-f":
ifile = value
me = os.path.basename(sys.argv[0])
monitoring = (me == "check_doh" or me == "check_dot")
if not monitoring:
name = None
message = None
try:
optlist, args = getopt.getopt (sys.argv[1:], "hvPker:f:d:t",
["help", "verbose", "dot", "head", "insecure", "POST", "repeat=", "file=", "delay="])
for option, value in optlist:
if option == "--help" or option == "-h":
usage()
sys.exit(0)
elif option == "--verbose" or option == "-v":
verbose = True
elif option == "--head" or option == "-e":
head = True
elif option == "--dot" or option == "-t":
dot = True
elif option == "--insecure" or option == "-k":
insecure = True
elif option == "--POST" or option == "-P":
post = True
elif option == "--repeat" or option == "-r":
tests = int(value)
if tests <= 1:
error("--repeat needs a value > 1")
elif option == "--delay" or option == "-d":
delay = float(value)
if delay <= 0:
error("--delay needs a value > 0")
elif option == "--file" or option == "-f":
ifile = value
else:
error("Unknown option %s" % option)
except getopt.error as reason:
usage(reason)
sys.exit(1)
if tests <= 1 and delay is not None:
error("--delay makes no sense if there is no repetition")
if post and head:
usage("POST or HEAD but not both")
sys.exit(1)
if dot and (post or head):
usage("POST or HEAD makes non sense for DoT")
sys.exit(1)
if ifile is None and (len(args) != 2 and len(args) != 3):
usage("Wrong number of arguments")
sys.exit(1)
if ifile is not None and len(args) != 1:
usage("Wrong number of arguments (if --file is used, do not indicate the domain name)")
sys.exit(1)
url = args[0]
if ifile is None:
name = args[1]
if len(args) == 3:
rtype = args[2]
else: # Monitoring plugin
dot = (me == "check_dot")
name = None
try:
optlist, args = getopt.getopt (sys.argv[1:], "H:n:p:V:t:Pih")
for option, value in optlist:
if option == "-H":
host = value
elif option == "-V":
vhostname = value
elif option == "-n":
name = value
elif option == "-t":
rtype = value
elif option == "-p":
path = value
elif option == "-P":
post = True
elif option == "-i":
insecure = True
elif option == "-h":
head = True
else:
# Should never occur, it is trapped by getopt
print("Unknown option %s" % option)
sys.exit(STATE_UNKNOWN)
except getopt.error as reason:
print("Option parsing problem %s" % reason)
sys.exit(STATE_UNKNOWN)
if len(args) > 0:
print("Too many arguments (\"%s\")" % args)
sys.exit(STATE_UNKNOWN)
if host is None or name is None:
print("Host (-H) and name to lookup (-n) are necessary")
sys.exit(STATE_UNKNOWN)
if post and head:
print("POST or HEAD but not both")
sys.exit(STATE_UNKNOWN)
if dot:
url = host
else:
if vhostname is None:
url = "https://%s/" % host
else:
error("Unknown option %s" % option)
except getopt.error as reason:
usage(reason)
sys.exit(1)
if tests <= 1 and delay is not None:
error("--delay makes no sense if there is no repetition")
if post and head:
usage("POST or HEAD but not both")
sys.exit(1)
if dot and (post or head):
usage("POST or HEAD makes non sense for DoT")
sys.exit(1)
if ifile is None and (len(args) != 2 and len(args) != 3):
usage("Wrong number of arguments")
sys.exit(1)
if ifile is not None and len(args) != 1:
usage("Wrong number of arguments (if --file is used, do not indicate the domain name)")
sys.exit(1)
url = args[0]
if ifile is None:
name = args[1]
if len(args) == 3:
rtype = args[2]
url = "https://%s/" % vhostname # host is ignored in that case
if path is not None:
if path.startswith("/"):
path = path[1:]
url += path
ok = True
conn = Connection(url, dot=dot, verbose=verbose, insecure=insecure, post=post, head=head)
start = time.time()
try:
if monitoring and dot and vhostname is not None:
extracheck = vhostname
else:
extracheck = None
conn = Connection(url, dot=dot, servername=extracheck, verbose=verbose, insecure=insecure, post=post, head=head)
except TimeoutError:
error("timeout")
if ifile is not None:
input = open(ifile)
for i in range (0, tests):
......@@ -280,14 +363,28 @@ for i in range (0, tests):
rtype = 'AAAA'
else:
(name, rtype) = line.split()
(rcode, msg) = do_test(conn, name, rtype)
(rcode, msg, size) = do_test(conn, name, rtype)
if (dot and rcode) or (not dot and rcode == 200):
print(msg)
if not monitoring:
print(msg)
else:
if size is not None and size > 0:
print("%s OK - %s" % (url, "No error for %s/%s, %i bytes received" % (name, rtype, size)))
else:
print("%s OK - %s" % (url, "No error"))
sys.exit(STATE_OK)
else:
if dot:
print("Error: %s" % msg, file=sys.stderr)
if not monitoring:
if dot:
print("Error: %s" % msg, file=sys.stderr)
else:
print("HTTP error %i: %s" % (rcode, msg.decode()), file=sys.stderr)
else:
print("HTTP error %i: %s" % (rcode, msg), file=sys.stderr)
if not dot:
print("%s HTTP error - %i: %s" % (url, rcode, msg.decode()))
else:
print("%s Error - %i: %s" % (url, rcode, msg))
sys.exit(STATE_CRITICAL)
ok = False
if tests > 1 and i == 0:
start2 = time.time()
......@@ -298,12 +395,19 @@ if tests > 1:
extra = ", %.2f ms/request if we ignore the first one" % ((stop-start2)*1000/(tests-1))
else:
extra = ""
print("\nTotal elapsed time: %.2f seconds (%.2f ms/request %s)" % (stop-start, (stop-start)*1000/tests, extra))
if not monitoring:
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:
sys.exit(0)
if not monitoring:
sys.exit(0)
else:
sys.exit(STATE_OK)
else:
sys.exit(1)
if not monitoring:
sys.exit(1)
else:
sys.exit(STATE_CRITICAL)
Markdown is supported
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