#!/usr/bin/perl -w
# Fetch and display wireless stats from an Apple Airport Extreme Base Station.
# Andre LaBranche, dre at mac dot com, 4/27/07
# Change History
# 5/3/07 - Fixed SNMP MIB URL, added --installmib option

use Switch;
use Getopt::Long qw(:config permute bundling);
use POSIX qw(strftime);

# User Tuneables Start

# We need to define base stations and their SNMP community names for querying.
# For slight obscurity purposes, we store a base64 encoded version of the snmp
# community name. Generate such a string at the command line like this:
#    echo "the password" | openssl enc -base64
# or just use the --encode option to this script.
# e.g. airport --encode "my password"

# Define base stations as shown here. You'll need the IP address and encoded
# form of the SNMP community name. Specify as many base stations as you like.


$basestations{"10.0.1.1"} = "dGhlIHBhc3N3b3JkCg==";

# Our friends. Store the MAC addresses like this (all caps, with spaces). If
# we find a wireless client in this list, we'll display the specified name
# instead of the MAC address.
$friends{"00 17 F2 02 8E 96"} = "donk";
$friends{"00 17 F2 02 2F 97"} = "dreness";

# snmpwalk binary location
$snmpbin = "/usr/bin/snmpwalk";

# User Tunables End

# some variables
@friends    = keys %friends;    # a list of friendly MAC addresses
$fieldOrder = "";               # used to hold order of output fields

# get and process command line arguments and options. We use Getopt::Long to
# handle the command line help and verbose options. Anything else gets passed
# to the &orderFields subroutine. This is so we can take these in order to
# build the output format.
GetOptions(
    'v+'       => \$debug,
    'h|help'   => \$printHelp,
    'd'        => \$delimited,
    '<>'       => \&orderFields,
    'installmib' => \&installmib,
    'encode=s' => \$pwhash
);

# process cli arguments / options
&printCliHelp if ( defined $printHelp );

# be sure you've installed Apple's Airport MIB:
# http://docs.info.apple.com/article.html?artnum=120227
if ( -e "/usr/share/snmp/mibs/airport-extreme.mib" ) {
}                               #zee goggles!
else {
    print "Can't find Airport MIB. Use --installmib to install it:\n";
    #print "cd /usr/share/snmp/mibs ; sudo curl -L -O ";
    #print "http://supportdownload.apple.com/download.info.apple.com/";
    #print "Apple_Support_Area/Apple_Software_Updates/Mac_OS_X/downloads/";
    #print "061-0652.20030619.5ibjt/airport-extreme.mib\n";
    die("Error: Missing SNMP MIB file.\n");
}

# make sure we have work to do
if ( !defined %basestations ) {
    print "Edit this script to configure base stations to monitor.\n";
    exit 1;
}

if ( defined $pwhash ) {
    $base64 = `echo "$pwhash" | openssl enc -base64`;
    print "base64 encoded version of $pwhash is: $base64\n";
    exit 0;
}

if ( !defined $debug ) { $debug = 0 }
print "Debug level: $debug\n" if ( $debug gt 0 );

# if user passed in a field specification, $fieldOrder would be populated
if ( defined $fieldOrder ) {

    # make an array of individual field spec characters
    @fields = split( //, $fieldOrder );

    # test each character and load @infos with the corresponding field name
    foreach $a (@fields) {
        switch ($a) {
            case "n" { push @infos, "wirelessPhysAddress" };
            case "y" { push @infos, "wirelessType" };
            case "s" { push @infos, "wirelessStrength" };
            case "r" { push @infos, "wirelessRate" };
            case "o" { push @infos, "wirelessNoise" };
            case "t" { push @infos, "wirelessNumTX" };
            case "e" { push @infos, "wirelessNumRX" };
            case "T" { push @infos, "wirelessNumTXErrors" };
            case "E" { push @infos, "wirelessNumRXErrors" };
            case "a" { push @infos, "wirelessTimeAssociated" };
            case "i" { push @infos, "wirelessLastRefreshTime" };
        };
    }
}

if ( grep ( /wireless/, @infos ) ) {

    # user passed in some options, we'll use them
}
else {

    # User didn't specify any output fields, so set up default output
    @infos = (
        'wirelessPhysAddress', 'wirelessType',
        'wirelessStrength',    'wirelessRate',
        'wirelessNoise',       'wirelessTimeAssociated',
        'wirelessLastRefreshTime'
    );
}

print "Using data field order: [@infos]\n" if ( $debug gt 0 );

# We need to build a format string appropriate to the output field order we'll
# be using. Pass &buildFormatStrings the list of fields, and it returns a
# matching array of format strings (used for printf later).
@formats = &buildFormatStrings(@infos);
print "Using printf formats: [@formats]\n" if ( $debug gt 1 );

# Query each base station in turn. We'll build a hash in the form
# $host{$key} = $value, where $host is the IP address, and $key is the name of
# an SNMP object, with corresponding $value.
foreach $host ( keys %basestations ) {
    chomp( $community = `echo $basestations{$host} | openssl enc -d -base64` );

    # Build the SNMP command. Walk the entire airport MIB ...
    $snmpcmd = "$snmpbin -m AIRPORT-BASESTATION-3-MIB -Osq -v 2c ";

    # ... use the supplied snmp community and host names
    $snmpcmd = $snmpcmd . "-c \"$community\" \"$host\" ";

    # ... and root the search at this OID
    $snmpcmd = $snmpcmd . "SNMPv2-SMI::enterprises.apple.airport";

    # Fire off the snmpwalk command. All the output is stored in @output.
    print "About to run SNMP command:\n$snmpcmd\n" if ( $debug gt 0 );
    @output = `$snmpcmd`;
    chomp @output;

    # step through each line of output and organize the data
    foreach $line (@output) {

        # match the interesting bits
        $line =~ /^(.*?)\s(.*?)$/;
        $key = $1;
        $val = $2;

        # store the two values as a hash element, if we got them both
        $host{$key} = "$val" if ( ( defined $key ) && ( defined $val ) );
        undef $key;
        undef $val;
    }

    # make sure we got useful output from the snmpwalk
    if ( !defined $host{"sysConfName.0"} ) {
        $snmpHelp = <<EOF;
Unable to query $host. Check SNMP configuration.
* Verify that you have defined the correct IP Address or host name for your
  base station(s). Do this near the top of this script.
* Verify that SNMP is enabled for this base station. Using the Airport Admin
  Utility, check "Base Station Options" under the "Airport" section.
* Have the correct SNMP community name? The SNMP community name is the same
  as the base station password used for remote administration.
* Try running in verbose mode (-v) for more diagnostic information.
EOF
        print $snmpHelp;
        next;
    }

    # Get the base station ID and other important info
    $name = $host{"sysConfName.0"};       # bssid
    $wlan = $host{"wirelessNumber.0"};    # number of wlan clients
    $dhcp = $host{"dhcpNumber.0"};        # number of dhcp clients
    if ( !$delimited ) {
        print "$host ($name) WLAN clients: $wlan  DHCP clients: $dhcp\n";
    }
    else {
        print "*,$host,$name,$wlan,$dhcp\n";
    }

    # make a list of data keys we got from the output. One of these for each
    # line of snmpwalk output.
    @keys = keys %host;

    # Look for lines containing MAC address, and make @MACs out of them. Each
    # of these lines also includes a wireless device identifier. It is used as
    # a key to that device's data across various parts of the SNMP MIB.
    @MACs = grep( /wirelessPhysAddress/, @keys );

    foreach $macline (@MACs) {

        # Extract the unique client ID mentioned above. Populate the @CLIENTS
        # arrayy. This array is a list of devices wirelessly associated with
        # this base station.
        $macline =~ /"".(.*?)$/;
        push @CLIENTS, $1;

        # A bit of reformatting on the MAC address as returned by snmpwalk...
        $mac = $host{$macline};
        $mac =~ s/"//g;
        $mac =~ s/^\s//;
        $mac =~ s/\s$//g;
        $host{$macline} = "$mac";

        # Look up this device's name in the %friends hash. If it exists, replace
        # the MAC address in the %host hash with the friendly name.
        if ( grep /$mac/, @friends ) {
            $host{$macline} = $friends{$mac};
        }
    }

    # Print the field headings with a bit of surrounding space
    if ( !defined $delimited ) {
        foreach $item (@infos) {
            print "  $headings{$item} ";
        }
        print "\n";
    }

    # for each device that is associated to the base station, we'll call the
    # getClientInfo subroutine, passing it the wireless client ID number and
    # the list of fields for which we want information. @results are returned.
    $size = @infos;    # so we know when to stop printing delimiters...
    print "printing $size attributes\n" if ( $debug gt 0 );
    foreach $client (@CLIENTS) {
        print "going to pass: [$client] and [@infos] to getClientInfo\n"
          if ( $debug gt 1 );
        @results = getClientInfo( "$client", "@infos" );
        print "got results [@results]\n" if ( $debug gt 1 );

        # $i is used here to keep track of our position in @CLIENTS
        $i = 0;
        print " " unless ( defined $delimited );

        # Step through each of the results and print, according to a
        # pre-determined format string. Some data requires pre-processing...
        foreach $item (@results) {
            print "Working result: [$infos[$i]]" if ( $debug gt 2 );

            # handle special cases (e.g. time)
            if  ( $infos[$i] eq "wirelessTimeAssociated" )
            {

                # format the duration in seconds in a more readable fashion
                if ( !defined $delimited ) {
		    $out = strftime ( ' %e %T ', gmtime $item );
		    print $out;
                }
                else {
		    $out = strftime ( '%e:%T', gmtime $item );
		    print $out;
                    #printf "%d:%d:%d:%d", ( gmtime $item )[ 7, 2, 1, 0 ];
                }
            }
            elsif ( $infos[$i] eq "wirelessLastRefreshTime" ) 
            {

                # format the duration in seconds in a more readable fashion
                if ( !defined $delimited ) {
                    $out = strftime ( ' %T ', gmtime $item );
                    print $out;
                }
                else {
                    $out = strftime ( '%T', gmtime $item );
		    print $out;
                }
	    } else {
                # print the data $results[$i] using the format string in
                # $formats[$i], or as comma delimited
                if ( !defined $delimited ) {
                    printf " $formats[$i]  ", "$results[$i]";
                }
                else {
                    printf "%s", "$results[$i]";
                }
            }
            print "," if ( ( $i <= ( $size - 2 ) ) && ( defined $delimited ) );
            print "End of loop $i...\n" if ( $debug gt 1 );
            $i++;
        }
        $i    = 0;
        $wlan = $host{"wirelessNumber.0"};
        $dhcp = $host{"dhcpNumber.0"};

        print "\n";
    }

    undef @CLIENTS;
    undef @MACs;
    undef %host;
}

# Receives wireless client ID and array of data object names.
# Returns array of data object values in the same order
# This sub is here because looking up the data we stored from the snmpwalk
# output requires looking for a specific pattern of data key name and wireless
# client id number. This sub prepares that pattern, which is also a key
# name in a %host hash, then retrieves the value of that hash element, storing
# it in @clientData.
sub getClientInfo {
    my @clientData;
    my $clientid = shift;
    my @objects  = shift;

    @objects = split( /\s/, $objects[0] );
    print "client is $client\n" if ( $debug gt 2 );
    foreach $obj (@objects) {
        print "working obj: $obj\n" if ( $debug gt 2 );
        $string = "$obj.\"\".$clientid";
        print "string is: $string\n" if ( $debug gt 2 );
        push( @clientData, "$host{$string}" );
    }

    #print "clientData is @clientData\n";
    return @clientData;
}

sub printCliHelp {
    $helpText = <<EOF;
Program Options:
      -h             print this help
      -d             output in comma delimited format
	             (base station ID lines are designated by '*')
      -v             verbose mode
	             (stacks up to 3 times)
      --encode 	     Specify plaintext to retrieve the base64 encoding.
		     This should be used to store the SNMP community name near
		     the top of this script.
      --installmib   Downloads the Apple Airport Extreme MIB
		   
Output Field Specification
   Each of these letters represents a piece of available information about a
   wireless device associated to the base station. The order in which the tokens
   are specified is used as the output field order. Do not prefix these tokens
   with "-" or "--".
       n       wirelessPhysAddress
       y       wirelessType
       s       wirelessStrength
       r       wirelessRate
       o       wirelessNoise
       t       wirelessNumTX
       e       wirelessNumRX
       T       wirelessNumTXErrors
       E       wirelessNumRXErrors
       a       wirelessTimeAssociated
       i       wirelessLastRefreshTime

Default output is equivalent to "nysroai".

Examples:
Debug mode with default fields:
airport -v

Comma-delimited mode with field specification:
airport -d nsroteTE

EOF
    print "$helpText";
    exit 0;
}

sub buildFormatStrings {

    #print "building format strings for [@infos]\n";
    # will use global @infos
    # returns array of format strings
    @formats = ();    # return this
    %formats = (

        # These are tweaked to align with the column headings. Not used if
        # running in $delimited mode.
        'wirelessPhysAddress'     => "%-17s",
        'wirelessType'            => "%-4s",
        'wirelessStrength'        => "%-4s",
        'wirelessRate'            => "%-4s",
        'wirelessNoise'           => "%-5s",
        'wirelessNumTX'           => "%-10s",
        'wirelessNumRX'           => "%-10s",
        'wirelessNumTXErrors'     => "%-10s",
        'wirelessNumRXErrors'     => "%-10s",
        'wirelessTimeAssociated'  => "%-8s",
        'wirelessLastRefreshTime' => "%-8s"
    );

    %headings = (
        'wirelessPhysAddress'     => "Name or Address  ",
        'wirelessType'            => "Type",
        'wirelessStrength'        => "Str ",
        'wirelessRate'            => "Rate",
        'wirelessNoise'           => "Noise",
        'wirelessNumTX'           => "TX Frames ",
        'wirelessNumRX'           => "RX Frames ",
        'wirelessNumTXErrors'     => "TX Errors ",
        'wirelessNumRXErrors'     => "RX Errors ",
        'wirelessTimeAssociated'  => "Connected ",
        'wirelessLastRefreshTime' => "Idle      "
    );
    foreach $a (@infos) {
        print "buildFormatStrings: got $a from infos...\n" if ( $debug gt 2 );
        print "buildFormatStrings: $a is: $formats{$a}\n"  if ( $debug gt 2 );
        push( @formats, "$formats{$a}" );
    }
    return @formats;
}

sub orderFields {
    $fieldOrder .= "$_[0]";
    print "fieldOrder is: $fieldOrder\n"
      if ( ( defined $debug ) && ( $debug gt 1 ) );
}

sub installmib {
  print "You will be prompted for your password.\n";
  system("cd /usr/share/snmp/mibs ; sudo curl -L -O http://supportdownload.apple.com/download.info.apple.com/Apple_Support_Area/Apple_Software_Updates/Mac_OS_X/downloads/061-0652.20030619.5ibjt/airport-extreme.mib ; cd ~");
  exit 0;
};
