Repository
Munin (contrib)
Last change
2020-10-28
Graph Categories
Family
contrib manual
Keywords
Language
Python (3.x)
License
GPL-2.0-only
Authors

snmp__airport

Name

Monitor various items of data from an Apple Airport Express/Extreme or a Time Capsule.

Installation

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)

Authors

Copyright (C) 2011 Chris Jones cmsj@tenshu.net

License

This script is released under the GNU GPL v2 license.

SPDX-License-Identifier: GPL-2.0-only

Magic Markers

#%# capabilities=
#%# family=contrib manual
#!/usr/bin/env python3
"""

=head1 NAME

Monitor various items of data from an Apple Airport Express/Extreme or a Time Capsule.


=head1 INSTALLATION

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:

=over 4

=item number of connected wireless clients

=item number of active DHCP leases

=item rate at which clients are connected (in Mb/s)

=item signal quality of connected clients (in dB)

=item noise level of connected clients (in dB)

=back


=head1 AUTHORS

Copyright (C) 2011 Chris Jones <cmsj@tenshu.net>


=head1 LICENSE

This script is released under the GNU GPL v2 license.

SPDX-License-Identifier: GPL-2.0-only


=head1 MAGIC MARKERS

 #%# capabilities=
 #%# family=contrib manual


=cut
"""
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'""", file=sys.stderr)
    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):
    """Examining 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 because 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:  # noqa: E722 (TODO: specify the expected exceptions)
        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, file=sys.stderr)
                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)