#!/usr/local/bin/python3.8

"""
Munin monitoring plug-in for BIND9 DNS statistics server. Tested
with BIND 9.10, 9.11, and 9.12, exporting version 3.x of the XML
statistics.

Copyright (c) 2013-2015, Shumon Huque. All rights reserved.
This program is free software; you can redistribute it and/or modify
it under the same terms as Python itself.
"""

import os, sys
import xml.etree.ElementTree as et
try:
    from urllib2 import urlopen                  # for Python 2
except ImportError:
    from urllib.request import urlopen           # for Python 3

VERSION = "0.31"

HOST = os.environ.get('HOST', "127.0.0.1")
PORT = os.environ.get('PORT', "8053")
INSTANCE = os.environ.get('INSTANCE', "")
SUBTITLE = os.environ.get('SUBTITLE', "")

STATS_TYPE = "xml"                           # will support json later
BINDSTATS_URL = "http://%s:%s/%s" % (HOST, PORT, STATS_TYPE)

if SUBTITLE != '':
    SUBTITLE = ' ' + SUBTITLE

GraphCategoryName = "dns_bind"

# Note: munin displays these graphs ordered alphabetically by graph title

GraphConfig = (

    ('dns_opcode_in' + INSTANCE,
     dict(title='BIND [00] Opcodes In',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Queries/sec',
          location="server/counters[@type='opcode']/counter",
          config=dict(type='DERIVE', min=0, draw='AREASTACK'))),

    ('dns_qtypes_in' + INSTANCE,
     dict(title='BIND [01] Query Types In',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Queries/sec',
          location="server/counters[@type='qtype']/counter",
          config=dict(type='DERIVE', min=0, draw='AREASTACK'))),

    ('dns_server_stats' + INSTANCE,
     dict(title='BIND [02] Server Stats',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Queries/sec',
          location="server/counters[@type='nsstat']/counter",
          fields=("Requestv4", "Requestv6", "ReqEdns0", "ReqTCP", "ReqTSIG",
                  "Response", "TruncatedResp", "RespEDNS0", "RespTSIG",
                  "QrySuccess", "QryAuthAns", "QryNoauthAns", "QryReferral",
                  "QryNxrrset", "QrySERVFAIL", "QryFORMERR", "QryNXDOMAIN",
                  "QryRecursion", "QryDuplicate", "QryDropped", "QryFailure",
                  "XfrReqDone", "UpdateDone", "QryUDP", "QryTCP"),
          config=dict(type='DERIVE', min=0))),

    ('dns_cachedb' + INSTANCE,
     dict(title='BIND [03] CacheDB RRsets',
          enable=True,
          stattype='cachedb',
          args='-l 0',
          vlabel='Count',
          location="views/view[@name='_default']/cache[@name='_default']/rrset",
          config=dict(type='GAUGE', min=0))),

    ('dns_resolver_stats' + INSTANCE,
     dict(title='BIND [04] Resolver Stats',
          enable=False,                         # appears to be empty
          stattype='counter',
          args='-l 0',
          vlabel='Count/sec',
          location="server/counters[@type='resstat']/counter",
          config=dict(type='DERIVE', min=0))),

    ('dns_resolver_stats_qtype' + INSTANCE,
     dict(title='BIND [05] Resolver Outgoing Queries',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Count/sec',
          location="views/view[@name='_default']/counters[@type='resqtype']/counter",
          config=dict(type='DERIVE', min=0))),

    ('dns_resolver_stats_view' + INSTANCE,
     dict(title='BIND [06] Resolver Stats',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Count/sec',
          location="views/view[@name='_default']/counters[@type='resstats']/counter",
          config=dict(type='DERIVE', min=0))),

    ('dns_cachestats' + INSTANCE,
     dict(title='BIND [07] Resolver Cache Stats',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Count/sec',
          location="views/view[@name='_default']/counters[@type='cachestats']/counter",
          fields=("CacheHits", "CacheMisses", "QueryHits", "QueryMisses",
                  "DeleteLRU", "DeleteTTL"),
          config=dict(type='DERIVE', min=0))),

    ('dns_cache_mem' + INSTANCE,
     dict(title='BIND [08] Resolver Cache Memory Stats',
          enable=True,
          stattype='counter',
          args='-l 0 --base 1024',
          vlabel='Memory In-Use',
          location="views/view[@name='_default']/counters[@type='cachestats']/counter",
          fields=("TreeMemInUse", "HeapMemInUse"),
          config=dict(type='GAUGE', min=0))),

    ('dns_socket_activity' + INSTANCE,
     dict(title='BIND [09] Socket Activity',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Active',
          location="server/counters[@type='sockstat']/counter",
          fields=("UDP4Active", "UDP6Active",
                  "TCP4Active", "TCP6Active",
                  "UnixActive", "RawActive"),
          config=dict(type='GAUGE', min=0))),

    ('dns_socket_stats' + INSTANCE,
     dict(title='BIND [10] Socket Rates',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Count/sec',
          location="server/counters[@type='sockstat']/counter",
          fields=("UDP4Open", "UDP6Open",
                  "TCP4Open", "TCP6Open",
                  "UDP4OpenFail", "UDP6OpenFail",
                  "TCP4OpenFail", "TCP6OpenFail",
                  "UDP4Close", "UDP6Close",
                  "TCP4Close", "TCP6Close",
                  "UDP4BindFail", "UDP6BindFail",
                  "TCP4BindFail", "TCP6BindFail",
                  "UDP4ConnFail", "UDP6ConnFail",
                  "TCP4ConnFail", "TCP6ConnFail",
                  "UDP4Conn", "UDP6Conn",
                  "TCP4Conn", "TCP6Conn",
                  "TCP4AcceptFail", "TCP6AcceptFail",
                  "TCP4Accept", "TCP6Accept",
                  "UDP4SendErr", "UDP6SendErr",
                  "TCP4SendErr", "TCP6SendErr",
                  "UDP4RecvErr", "UDP6RecvErr",
                  "TCP4RecvErr", "TCP6RecvErr"),
          config=dict(type='DERIVE', min=0))),

    ('dns_zone_stats' + INSTANCE,
     dict(title='BIND [11] Zone Maintenance',
          enable=False,
          stattype='counter',
          args='-l 0',
          vlabel='Count/sec',
          location="server/counters[@type='zonestat']/counter",
          config=dict(type='DERIVE', min=0))),

    ('dns_memory_usage' + INSTANCE,
     dict(title='BIND [12] Memory Usage',
          enable=True,
          stattype='memory',
          args='-l 0 --base 1024',
          vlabel='Memory In-Use',
          location='memory/summary',
          fields=("ContextSize", "BlockSize", "Lost", "InUse"),
          config=dict(type='GAUGE', min=0))),

    ('dns_adbstat' + INSTANCE,
     dict(title='BIND [13] adbstat',
          enable=True,
          stattype='counter',
          args='-l 0',
          vlabel='Count',
          location="views/view[@name='_default']/counters[@type='adbstat']/counter",
          config=dict(type='GAUGE', min=0))),

)


def unsetenvproxy():
    """Unset HTTP Proxy environment variables that might interfere"""
    for proxyvar in [ 'http_proxy', 'HTTP_PROXY' ]:
        os.unsetenv(proxyvar)
    return


def getstatsversion(etree):
    """return version of BIND statistics"""
    return etree.attrib['version']


def getdata(graph, etree, getvals=False):

    stattype = graph[1]['stattype']
    location = graph[1]['location']

    if stattype == 'memory':
        return getdata_memory(graph, etree, getvals)
    elif stattype == 'cachedb':
        return getdata_cachedb(graph, etree, getvals)

    results = []
    counters = etree.findall(location)

    if counters is None:                     # empty result
        return results

    for c in counters:
        key = c.attrib['name']
        val = c.text
        if getvals:
            results.append((key, val))
        else:
            results.append(key)
    return results


def getdata_memory(graph, etree, getvals=False):

    location = graph[1]['location']

    results = []
    counters = etree.find(location)

    if counters is None:                     # empty result
        return results

    for c in counters:
        key = c.tag
        val = c.text
        if getvals:
            results.append((key, val))
        else:
            results.append(key)
    return results


def getdata_cachedb(graph, etree, getvals=False):

    location = graph[1]['location']

    results = []
    counters = etree.findall(location)

    if counters is None:                     # empty result
        return results

    for c in counters:
        key = c.find('name').text
        val = c.find('counter').text
        if getvals:
            results.append((key, val))
        else:
            results.append(key)
    return results


def validkey(graph, key):
    fieldlist = graph[1].get('fields', None)
    if fieldlist and (key not in fieldlist):
        return False
    else:
        return True


def get_etree_root(url):
    """Return the root of an ElementTree structure populated by
    parsing BIND9 statistics obtained at the given URL"""

    data = urlopen(url)
    return et.parse(data).getroot()


def muninconfig(etree):
    """Generate munin config for the BIND stats plugin"""

    for g in GraphConfig:
        if not g[1]['enable']:
            continue
        print("multigraph %s" % g[0])
        print("graph_title %s" % g[1]['title'] + SUBTITLE)
        print("graph_args %s" % g[1]['args'])
        print("graph_vlabel %s" % g[1]['vlabel'])
        print("graph_category %s" % GraphCategoryName)

        data = getdata(g, etree, getvals=False)
        if data != None:
            for key in data:
                if validkey(g, key):
                    print("%s.label %s" % (key, key))
                    if 'draw' in g[1]['config']:
                        print("%s.draw %s" % (key, g[1]['config']['draw']))
                    print("%s.min %s" % (key, g[1]['config']['min']))
                    print("%s.type %s" % (key, g[1]['config']['type']))
        print('')


def munindata(etree):
    """Generate munin data for the BIND stats plugin"""

    for g in GraphConfig:
        if not g[1]['enable']:
            continue
        print("multigraph %s" % g[0])
        data = getdata(g, etree, getvals=True)
        if data != None:
            for (key, value) in data:
                if validkey(g, key):
                    print("%s.value %s" % (key, value))
        print('')


def usage():
    """Print plugin usage"""
    print("""\
\nUsage: bind9stats.py [config|statsversion]\n""")
    sys.exit(1)


if __name__ == '__main__':

    tree = get_etree_root(BINDSTATS_URL)

    args = sys.argv[1:]
    argslen = len(args)
    unsetenvproxy()

    if argslen == 0:
        munindata(tree)
    elif argslen == 1:
        if args[0] == "config":
            muninconfig(tree)
        elif args[0] == "statsversion":
            print("bind9stats %s version %s" % (STATS_TYPE, getstatsversion(tree)))
        else:
            usage()
    else:
        usage()
