From 23a5a6f4cba28f52de0f90cea08651d576e347f4 Mon Sep 17 00:00:00 2001 From: Chris Jones Date: Thu, 14 Mar 2013 12:30:16 +0000 Subject: [PATCH] Add plugin for monitoring Apple Airport devices via SNMP --- plugins/snmp/snmp__airport | 355 +++++++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 plugins/snmp/snmp__airport diff --git a/plugins/snmp/snmp__airport b/plugins/snmp/snmp__airport new file mode 100644 index 00000000..acfc8047 --- /dev/null +++ b/plugins/snmp/snmp__airport @@ -0,0 +1,355 @@ +#!/usr/bin/python +""" +Munin plugin to monitor various items of data from an Apple Airport +Express/Extreme or a Time Capsule. + +v1.0 by Chris Jones +Copyright (C) 2011 Chris Jones +This script is released under the GNU GPL v2 license. + +To use this plugin, use specially named symlinks: + +cd /etc/munin/plugins +ln -s /path/to/snmp__airport snmp_myairport_airport_clients +ln -s /path/to/snmp__airport snmp_myairport_airport_dhcpclients +ln -s /path/to/snmp__airport snmp_myairport_airport_rate +ln -s /path/to/snmp__airport snmp_myairport_airport_signal +ln -s /path/to/snmp__airport snmp_myairport_airport_noise + +NOTE: the name 'myairport' should be a valid hostname or IP address for your + Airport. It can be any value, but it must not include the character '_'. + +Now add a virtual host entry to your munin server's munin.conf: + +[myairport] + address 123.123.123.123 + user_node_name no + +(with the correct IP address, obviously) + +this will create a virtual host in munin for the airport named 'myairport' and +produce graphs for: + * number of connected wireless clients + * number of active DHCP leases + * rate at which clients are connected (in Mb/s) + * signal quality of connected clients (in dB) + * noise level of connected clients (in dB) + +# Magic markers +#%# capabilities= +#%# family=contrib manual +""" +import sys +import os +try: + import netsnmp +except ImportError: + print """ERROR: Unable to import netsnmp. +Please install the Python bindings for libsnmp. +On Debian/Ubuntu machines this package is named 'libsnmp-python'""" + sys.exit(-3) + +DEBUG=None +CMDS=['type', 'rates', 'time', 'lastrefresh', 'signal', 'noise', 'rate', 'rx', + 'tx', 'rxerr', 'txerr'] +CMD=None +DESTHOST=None +NUMCLIENTS=None +NUMDHCPCLIENTS=None +WANIFINDEX=None + +def dbg(text): + """Print some debugging text if DEBUG=1 is in our environment""" + if DEBUG is not None: + print "DEBUG: %s" % text + +def usage(): + """Print some usage information about ourselves""" + print __doc__ + +def parseName(name): + """Examing argv[0] (i.e. the name of this script) for the hostname we should + be talking to and the type of check we want to run. The hostname should be + a valid, resolvable hostname, or an IP address. The command can be any of: + * clients - number of connected wireless clients + * signal - dB reported by the wireless clients for signal strength + * noise - dB reported by the wireless clients for noise level + * rate - Mb/s rate the wireless clients are connected at + + The name should take the form snmp_HOSTORIP_airport_COMMAND + """ + bits = name.split('_') + if len(bits) >= 4: + destHost = bits[1] + cmd = bits[3] + dbg("parseName split '%s' into '%s'/'%s'" % (name, destHost, cmd)) + return (destHost, cmd) + else: + dbg("parseName found an inconsistent name: '%s'" % name) + return None + +def tableToDict(table, num): + """The netsnmp library returns a tuple with all of the data, it is not in any + way formatted into rows. This function converts the data into a structured + dictionary, with each key being the MAC address of a wireless client. The + associated value will be a dictionary containing the information available + about the client: + * type - 1 = sta, 2 = wds + * rates - the wireless rates available to the client + * time - length of time the client has been connected + * lastrefresh - time since the client last refreshed + * signal - dB signal strength reported by the client (or -1) + * noise - dB noise level reported by the client (or -1) + * rate - Mb/s rate the client is connected at + * rx - number of packets received by the client + * tx - number of packets transmitted by the client + * rxerr - number of error packets received by the client + * txerr - number of error packets transmitted by the client + """ + table = list(table) + clients = [] + clientTable = {} + + # First get the MACs + i = num + while i > 0: + data = table.pop(0) + clients.append(data) + clientTable[data] = {} + dbg("tableToDict: found client '%s'" % data) + i = i - 1 + + for cmd in CMDS: + i = 0 + while i < num: + data = table.pop(0) + clientTable[clients[i]][cmd] = data + dbg("tableToDict: %s['%s'] = %s" % (clients[i], cmd, data)) + i = i + 1 + + return clientTable + +def getNumClients(): + """Returns the number of wireless clients connected to the Airport we are + examining. This will only ever be polled via SNMP once per invocation. If + called a second time, it will just return the first value it found. This is + intended to be an optimisation to reduce SNMP roundtrips because this script + should not be long-running""" + global NUMCLIENTS + wirelessNumberOID = '.1.3.6.1.4.1.63.501.3.2.1.0' + + # Dumbly cache this so we only look it up once. + if NUMCLIENTS is None: + NUMCLIENTS = int(netsnmp.snmpget(netsnmp.Varbind(wirelessNumberOID), + Version=2, DestHost=DESTHOST, + Community='public')[0]) + dbg("getNumClients: polled SNMP for client number") + + dbg("getNumClients: found %d clients" % NUMCLIENTS) + return NUMCLIENTS + +def getNumDHCPClients(): + """Returns the number of DHCP clients with currently active leases. This + will only ever be polled via SNMP once per invocation. If called a second + time, it will just return the first value it found. This is intended to be + an optimisation to reduce SNMP roundtrips becaues this script should not be + long-running""" + global NUMDHCPCLIENTS + dhcpNumberOID = '.1.3.6.1.4.1.63.501.3.3.1.0' + + # Dumbly cache this so we only look it up once. + if NUMDHCPCLIENTS is None: + NUMDHCPCLIENTS = int(netsnmp.snmpget(netsnmp.Varbind(dhcpNumberOID), + Version=2, DestHost=DESTHOST, + Community='public')[0]) + dbg("getNumDHCPClients: polled SNMP for dhcp client number") + + dbg("getNumDHCPClients: found %d clients" % NUMDHCPCLIENTS) + return NUMDHCPCLIENTS + +def getExternalInterface(): + """Returns the index of the WAN interface of the Airport. This will only + ever be polled via SNMP once per invocation, per getNum*Clients(). See + above.""" + global WANIFINDEX + iFaceNames = '.1.3.6.1.2.1.2.2.1.2' + + if WANIFINDEX is None: + interfaces = list(netsnmp.snmpwalk(netsnmp.Varbind(iFaceNames), + Version=2, DestHost=DESTHOST, + Community='public')) + dbg("getExternalInterface: found interfaces: %s" % interfaces) + try: + WANIFINDEX = interfaces.index('mgi1') + 1 + except ValueError: + print "ERROR: Unable to find WAN interface mgi1" + print interfaces + sys.exit(-3) + + dbg("getExternalInterface: found mgi1 at index: %d" % WANIFINDEX) + return WANIFINDEX + +def getExternalInOctets(): + """Returns the number of octets of inbound traffic on the WAN interface""" + return getOctets('In') + +def getExternalOutOctets(): + """Returns the number of octets of outbound traffic on the WAN interface""" + return getOctets('Out') + +def getOctets(direction): + """Returns the number of octets of traffic on the WAN interface in the + requested direction""" + index = getExternalInterface() + + if direction == 'In': + iFaceOctets = '.1.3.6.1.2.1.2.2.1.10.%s' % index + else: + iFaceOctets = '.1.3.6.1.2.1.2.2.1.16.%s' % index + + return int(netsnmp.snmpget(netsnmp.Varbind(iFaceOctets), + Version=2, DestHost=DESTHOST, + Community='public')[0]) + +def getWanSpeed(): + """Returns the speed of the WAN interface""" + ifSpeed = "1.3.6.1.2.1.2.2.1.5.%s" % getExternalInterface() + dbg("getWanSpeed: OID for WAN interface speed: %s" % ifSpeed) + try: + wanSpeed = int(netsnmp.snmpget(netsnmp.Varbind(ifSpeed), + Version=2, DestHost=DESTHOST, + Community='public')[0]) + except: + dbg("getWanSpeed: Unable to probe for data, defaultint to 10000000") + wanSpeed = 10000000 + + return wanSpeed + +def getData(): + """Returns a dictionary populated with all of the wireless clients and their + metadata""" + wirelessClientTableOID = '.1.3.6.1.4.1.63.501.3.2.2.1' + + numClients = getNumClients() + + if numClients == 0: + # FIXME: what's actually the correct munin plugin behaviour if there is no + # data to be presented? + dbg("getData: 0 clients found, exiting") + sys.exit(0) + + dbg("getData: polling SNMP for client table") + clientTable = netsnmp.snmpwalk(netsnmp.Varbind(wirelessClientTableOID), + Version=2, DestHost=DESTHOST, + Community='public') + clients = tableToDict(clientTable, numClients) + + return clients + +def main(clients=None): + """This function fetches metadata about wireless clients if needed, then + displays whatever values have been requested""" + if clients is None and CMD not in ['clients', 'dhcpclients', 'wanTraffic']: + clients = getData() + + if CMD == 'clients': + print "clients.value %s" % getNumClients() + elif CMD == 'dhcpclients': + print "dhcpclients.value %s" % getNumDHCPClients() + elif CMD == 'wanTraffic': + print "recv.value %s" % getExternalInOctets() + print "send.value %s" % getExternalOutOctets() + else: + for client in clients: + print "MAC_%s.value %s" % (client, clients[client][CMD]) + +if __name__ == '__main__': + clients = None + if os.getenv('DEBUG') == '1': + DEBUG = True + netsnmp.verbose = 1 + else: + netsnmp.verbose = 0 + + BITS = parseName(sys.argv[0]) + if BITS is None: + usage() + sys.exit(0) + else: + DESTHOST = BITS[0] + CMD = BITS[1] + + if len(sys.argv) > 1: + if sys.argv[1] == 'config': + print """ +graph_category network +host_name %s""" % DESTHOST + + if CMD == 'signal': + print """graph_args -l 0 --lower-limit -100 --upper-limit 0 +graph_title Wireless client signal +graph_scale no +graph_vlabel dBm Signal""" + elif CMD == 'noise': + print """graph_args -l 0 --lower-limit -100 --upper-limit 0 +graph_title Wireless client noise +graph_scale no +graph_vlabel dBm Noise""" + elif CMD == 'rate': + print """graph_args -l 0 --lower-limit 0 --upper-limit 500 +graph_title Wireless client WiFi rate +graph_scale no +graph_vlabel WiFi Rate""" + elif CMD == 'clients': + print """graph_title Number of connected clients +graph_args --base 1000 -l 0 +graph_vlabel number of wireless clients +graph_info This graph shows the number of wireless clients connected +clients.label clients +clients.draw LINE2 +clients.info The number of clients.""" + elif CMD == 'dhcpclients': + print """graph_title Number of active DHCP leases +graph_args --base 1000 -l 0 +graph_vlabel number of DHCP clients +graph_info This graph shows the number of active DHCP leases +dhcpclients.label leases +dhcpclients.draw LINE2 +dhcpclients.info The number of leases.""" + elif CMD == 'wanTraffic': + speed = getWanSpeed() + print """graph_title WAN interface traffic +graph_order recv send +graph_args --base 1000 +graph_vlabel bits in (-) / out (+) per ${graph_period} +graph_category network +graph_info This graph shows traffic for the mgi1 network interface +send.info Bits sent/received by this interface. +recv.label recv +recv.type DERIVE +recv.graph no +recv.cdef recv,8,* +recv.max %s +recv.min 0 +send.label bps +send.type DERIVE +send.negative recv +send.cdef send,8,* +send.max %s +send.min 0""" % (speed, speed) + else: + print "Unknown command: %s" % CMD + sys.exit(-2) + + if CMD in ['clients', 'dhcpclients', 'wanTraffic']: + # This is static, so we sent the .label data above + pass + else: + clients = getData() + for client in clients: + print "MAC_%s.label %s" % (client, client) + + sys.exit(0) + else: + main(clients) +