#
# Extract ARP table from 3Com SuperStack II switches (3900, 9300)
# and CoreBuilder 3500.
#
# $Id: ARP.pm,v 1.20 1999/06/03 15:09:09 trockij Exp $
#
# Copyright (C) 1998 Jim Trocki
#
#    This program is free software; you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation; either version 2 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program; if not, write to the Free Software
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
package A3Com::ARP;
require 5.004;

use strict;
use vars qw($VERSION);
use A3Com::Base;
use Expect;
use SNMP;

$VERSION = "0.02";

sub _convert_arp;
sub normalize_mac;
sub lookup_mac_history;

sub new {
    my $proto = shift;
    my $class = ref($proto) || $proto;
    my %vars = @_;

    my $self  = {};
    $self->{"IPMAC"} = {};
    $self->{"MACIP"} = {};
    $self->{"ERROR"} = "";
    $self->{"SWITCH"} = "";
    $self->{"READ_METHOD"} = \&snmp_read;
    $self->{"LOGIN"} = "read";
    $self->{"PASSWORD"} = "";
    $self->{"LOG"} = 0;
    $self->{"CACHE"} = 1;
    $self->{"GLOBALCACHE"} = 1;
    $self->{"CACHETIME"} = $A3Com::Base::DEFAULT_CACHETIME;

    $self->{"GLOBALCACHEDIR"} = "/usr/local/share/a3com";
    if (defined $ENV{"HOME"}) {
        $self->{"CACHEDIR"} = $ENV{"HOME"} . "/.a3com";
    } else {
	$self->{"CACHEDIR"} = ".a3com";
    }

    my %c = &A3Com::Base::_read_conf ($self, $ENV{"A3COM_CONF"});
    for (keys %c) {
    	$self->{$_} = $c{$_};
    }

    if (defined $ENV{"A3COM_GLOBALCACHEDIR"}) {
    	$self->{"GLOBALCACHEDIR"} = $ENV{"A3COM_GLOBALCACHEDIR"};
    }

    if (defined $ENV{"A3COM_CACHEDIR"}) {
    	$self->{"CACHEDIR"} = $ENV{"A3COM_CACHEDIR"};
    }
    $self->{"_LOADED"} = 0;

    for my $k (keys %vars) {
    	$self->{$k} = $vars{$k} if ($vars{$k} ne "");
    }

    bless ($self, $class);
    return $self;
}


sub cachedir {
    my $self = shift;
    if (@_) { $self->{"CACHEDIR"} = shift }
    return $self->{"CACHEDIR"};
}


sub globalcachedir {
    my $self = shift;
    if (@_) { $self->{"GLOBALCACHEDIR"} = shift }
    return $self->{"GLOBALCACHEDIR"};
}


sub cachetime {
    my $self = shift;
    if (@_) { $self->{"CACHETIME"} = shift }
    return $self->{"CACHETIME"};
}


sub cache {
    my $self = shift;
    if (@_) { $self->{"CACHE"} = shift }
    return $self->{"CACHE"};
}


sub globalcache {
    my $self = shift;
    if (@_) { $self->{"GLOBALCACHE"} = shift }
    return $self->{"GLOBALCACHE"};
}


sub switch {
    my $self = shift;
    if (@_) { $self->{"SWITCH"} = shift }
    return $self->{"SWITCH"};
}


sub error {
    my $self = shift;
    if (@_) { $self->{"ERROR"} = shift }
    return $self->{"ERROR"};
}


sub log {
    my $self = shift;
    if (@_) { $self->{"LOG"} = shift }
    return $self->{"LOG"};
}


#
# given a MAC address, return the list of IP addresses
# which it resolves to
#
sub ip {
    my $self = shift;
    my $mac = A3Com::Base::_mac_normalize (shift);

    $self->{"ERROR"} = "";

    return undef if (!defined $self->_load_cache ($self));

    if (!defined $self->{"MACIP"}->{$mac}) {
	$self->{"ERROR"} = "$mac not found";
    	return (undef);
    }
    return @{$self->{"MACIP"}->{$mac}};
}


#
# given an IP address, return the MAC address which it resolves to
#
sub mac {
    my ($self, $ip) = @_;

    $self->{"ERROR"} = "";

    return undef if (!defined $self->_load_cache ($self));

    return $self->{"IPMAC"}->{$ip};
}


sub arp_table {
    my $self = shift;

    $self->{"ERROR"} = "";

    return undef if (!defined $self->_load_cache ($self));

    return %{$self->{"IPMAC"}};
}


sub ip_addrs {
    my $self = shift;

    $self->{"ERROR"} = "";

    return undef if (!defined $self->_load_cache ($self));

    return keys %{$self->{"IPMAC"}};
}


sub mac_addrs {
    my $self = shift;

    $self->{"ERROR"} = "";

    return undef if (!defined $self->_load_cache ($self));

    return keys %{$self->{"MACIP"}};
}


sub file_read {
    my ($self, $file) = @_;
    my ($mac, @ip);

    $self->{"ERROR"} = "";

    if (!defined $file) {
	if (! -d $self->{"CACHEDIR"}) {
	    $self->{"ERROR"} = "CACHEDIR is not a directory";
	    return undef;
	}
    	$file = "$self->{CACHEDIR}/$self->{SWITCH}.arp";
    }

    if (!open (I, $file)) {
    	$self->{"ERROR"} = "$!";
	return undef;
    }

    while (<I>) {
    	next if (/^\s*#/ || /^\s*$/);
	chomp;
	($mac, @ip) = split;
	@{$self->{"MACIP"}->{$mac}} = @ip;
	for (@ip) {
	    $self->{"IPMAC"}->{$_} = $mac;
	}
    }

    close (I);

    $self->{"LOADED"} = 1;
}


sub file_write {
    my ($self, $file) = @_;
    my ($ip, $mac);

    $self->{"ERROR"} = "";

    if (!defined $file) {
        my $cd;

        if ($self->{"GLOBALCACHE"}) {
            $cd = "GLOBALCACHEDIR";
        } else {
            $cd = "CACHEDIR";
        }

        if (! -d $self->{$cd}) {
            $self->{"ERROR"} = "$cd is not a directory";
            return undef;
        }
        $file = "$self->{$cd}/$self->{SWITCH}.arp";
    }

    if (!open (O, ">$file")) {
    	$self->{"ERROR"} = "$!";
	return undef;
    }

    print O "#\n# ARP table for $self->{SWITCH} as of " . time . "\n#\n";
    foreach $mac (keys %{$self->{"MACIP"}}) {
    	print O "$mac @{$self->{MACIP}->{$mac}}\n";
    }
    close (O);
}


#
# Read the ARP table from a host using MIB-2
#
sub snmp_read {
    my $self = shift;
    my ($s, $var, $vars, $mac, $ip);

    $self->{"ERROR"} = "";

    if ($self->{"SWITCH"} eq "") {
    	$self->{"ERROR"} = "no switch defined";
	return undef;
    }

    if (!defined ($s = new SNMP::Session (
		    DestHost => $self->{"SWITCH"},
		    Community => ($self->{"PASSWORD"} or "public"),
		    Timeout => 30_000_000))) {
	$self->{"ERROR"} = "could not create SNMP session: " .
		$s->{"ErrorStr"};
	return undef;
    }

    $var = new SNMP::Varbind (
	['atIfIndex', 0],
    );

    for (;;) {
	$s->getnext ($var);
	last if ($var->tag ne "atIfIndex");

	if ($s->{"ErrorStr"}) {
	    $self->{"ERROR"} = $s->{"ErrorStr"};
	    return undef;
	}

	$vars = new SNMP::VarList (
	    ["atPhysAddress", $var->iid],
	    ["atNetAddress", $var->iid],
	);

	$s->get ($vars);
	if ($s->{"ErrorStr"}) {
	    $self->{"ERROR"} = $s->{"ErrorStr"};
	    return undef;
	}

	$mac = sprintf ("%02x:%02x:%02x:%02x:%02x:%02x", unpack ("C6", $vars->[0]->val));

	$ip = $vars->[1]->val;

	$self->{"IPMAC"}->{$ip} = $mac;
	push @{$self->{"MACIP"}->{$mac}}, $vars->[1]->val;
    }

    return 1;
}


#
# Use Expect + telnet to read the ARP table from a 3Com switch.
# This used to be the most efficient way to retrieve the ARP table
# with the old SuperStack / CoreBuilder software (version 1.*).
# The preferred method is to use snmp_read.
#
sub telnet_read {
    my $self = shift;
    my ($ARP, $s, @n);

    $self->{"ERROR"} = "";

    $s = Expect->spawn ("telnet $self->{SWITCH}");
    if (!$s) {
    	$self->{"ERROR"} = "could not telnet to $self->{SWITCH}";
	return undef;
    }

    $s->log_stdout($self->{LOG});

    #
    # log in
    #
    if (!$s->expect (30, ("Select access level (read, write, administer): "))) {
    	$self->{"ERROR"} = "did not get login prompt in time";
	return undef;
    }
    $s->clear_accum;

    print $s "$self->{LOGIN}\r";

    if (!$s->expect (8, ("Password:"))) {
    	$self->{"ERROR"} = "did not get password prompt";
	return undef;
    }
    $s->clear_accum;

    print $s "$self->{PASSWORD}\r";

    @n = $s->expect (8, "Incorrect password", "Select menu option: ");
    if ($n[0] == 1) {
	$s->hard_close();
	$self->{"ERROR"} = "incorrect password for $self->{SWITCH}";
	return undef;
    } elsif (!defined $n[0] && $n[1] eq "1:TIMEOUT") {
	$s->hard_close();
	$s->{"ERROR"} = "timeout logging in to $self->{SWITCH}, it says {$n[3]}";
	return undef;
    }
    $s->clear_accum;


    print $s "ip arp\r";

    @n = $s->expect (45, "Select menu option:");
    $ARP = $n[3];
    $s->clear_accum;

    if (!defined ($n[0])) {
	$s->hard_close();
	$s->{"ERROR"} = "error ($n[1]) while getting ARP table from $self->{SWITCH}\n$n[3]";
	return undef;
    }

    #
    # log out politely
    #
    print $s "logout\r";
    if ($s->expect (8, "Exiting") != 1) {
	$s->hard_close();
	$s->{"ERROR"} = "did not get logout confirmation";
	return undef;
    }

    $s->hard_close();

    _convert_arp $self, $ARP;
}


#
# convert output from telnet_read_arp into hashes indexed by
# IP address and MAC address
#
sub _convert_arp {
    my $self = shift;
    my $ARP = shift;
    my ($l, $ip, $type, $if, $mac, $rest);

    while ($ARP =~ /([^\n\r]+\n\r)/sg) {
	$l = $1;
	next if ($l !~ /^\d+\.\d+/);
	$l =~ s/[\r\n]*$//;
	($ip, $type, $if, $mac, $rest) = split (/\s+/, $l, 5);
	next if ($mac =~ /unresolved/i);
	$mac = normalize_mac ($mac);

	$self->{"IPMAC"}->{$ip} = $mac;
	push @{$self->{"MACIP"}->{$mac}}, $ip;
    }
}


sub _load_cache {
    my $self = shift;

    if (!$self->{"_LOADED"}) {
	if ($self->{"GLOBALCACHE"}) {
	    return undef if (!defined (A3Com::Base::_load_global ($self, ".arp")));
	} else {
	    return undef if (!defined (A3Com::Base::_load ($self, ".arp")));
	}
	$self->{"_LOADED"} = 1;
    }
}


#
# load an ARP history structure from a file
#
sub load_history {
    my $self = shift;
    my $file = shift;
    my $a;

    $self->{"ERROR"} = "";

    if (!defined $file) {
	if (! -d $self->{"GLOBALCACHEDIR"}) {
	    $self->{"ERROR"} = "GLOBALCACHEDIR is not a directory";
	    return undef;
	}
    	$file = "$self->{GLOBALCACHEDIR}/global.arphist";
    }

    if (!open (I, $file)) {
	$self->{"ERROR"} = "$!";
    	return undef;
    }

    my $ip = "";
    my $first_mac_in_seq = 0;

    my $l;
    while (defined ($l = <I>)) {
    	next if ($l =~ /^\s*#/ || $l =~ /^\s*$/);

	if ($l =~ /^(\d+\.\d+\.\d+\.\d+)/) {
	    $ip = $1;
	    $first_mac_in_seq = 1;
	    next;
	}

	if ($ip eq "") {
	    # error
	    next;
	}

	my ($t, $m) = ($l =~ /
	    (\d+)
	    \s+
	    (([0-9a-f]{1,2}:){5}[0-9a-f]{1,2})
	    \s* $
	/ix);
	
	if ($t eq "") {
	    # error
	    next;
	}

	$m = normalize_mac ($m);

	# first_noticed
	if ($first_mac_in_seq) {
	    $a->{$ip}->{"current"} = $m;
	    $a->{$ip}->{"current_time"} = $t;
	    $first_mac_in_seq = 0;
	}

	$a->{$ip}->{"history"}->{$t} = $m;
    }

    close (I);

    return $a;
}


#
# save an ARP history structure to a file
#
sub save_history {
    my $self = shift;
    my $h = shift;
    my $file = shift;

    $self->{"ERROR"} = "";

    if (!defined $file) {
	if (! -d $self->{"GLOBALCACHEDIR"}) {
	    $self->{"ERROR"} = "GLOBALCACHEDIR is not a directory";
	    return undef;
	}
    	$file = "$self->{GLOBALCACHEDIR}/global.arphist";

    }

    if (!open (O, ">$file")) {
	$self->{"ERROR"} = "$!";
    	return undef;
    }

    my $t = time;
    my $lt = localtime ($t);

    print O <<EOF;
#
# ARP history for $t $lt
# 
EOF

    foreach my $ip (sort keys %{$h}) {
	print O "$ip\n";
    	foreach my $t (sort {$a <=> $b} keys %{$h->{$ip}->{"history"}}) {
	    print O "\t$t $h->{$ip}->{history}->{$t}\n";
	}
    }

    close (O);
}


#
# Merge a current ARP table into a historic ARP table
#
# %a = $self->arp_table;
# $c = \%a;
# $h = history
# $t = time
#
sub merge_current_with_history {
    my $self = shift;
    my $c = shift;
    my $h = shift;
    my $t = shift;

    my $n = $h;

    foreach my $ip (keys %{$c}) {
	#
	# IP address does not already exist in history
	#
    	if (!defined $h->{$ip}) {
	    $n->{$ip}->{"current"} = $c->{$ip}; # current MAC addr
	    $n->{$ip}->{"current_time"} = $t;
	    $n->{$ip}->{"history"}->{$t} = $c->{$ip}; # current MAC addr
	    next;
	}

	#
	# IP address already exists in history, MAC same
	#
	if ($h->{$ip}->{"current"} eq $c->{$ip}) {
	    $n->{$ip}->{"current"} = $c->{$ip};
	    $n->{$ip}->{"current_time"} = $t;

	    delete $n->{$ip}->{"history"}->{
	    	(reverse sort {$a <=> $b} keys %{$h->{$ip}->{"history"}})[0]
	    };
	    $n->{$ip}->{"history"}->{$t} = $c->{$ip};

	#
	# IP address already exists in history, MAC differs 
	#
	} else {
	    $n->{$ip}->{"current"} = $c->{$ip};
	    $n->{$ip}->{"current_time"} = $t;

	    $n->{$ip}->{"history"}->{$t} = $c->{$ip};
	}
    }

    return $n;
}


#
# given a MAC addr and a ARP history table,
# return the IPs which resolve to that MAC addr
#
sub lookup_mac_history {
    my $self = shift;
    my $h = shift;
    my $mac = shift;

    $mac = normalize_mac ($mac);

    my @ips = ();
    foreach my $ip (keys %{$h}) {
	push (@ips, $ip) if ($h->{$ip}->{"current"} eq $mac);
    }

    @ips;
}


#
# convert a MAC addr in an arbitrary format to something
# which resembles 01:02:03:0a:0b:0c
#
sub normalize_mac {
    my $mac = shift;

    my @a  = split (/[-:]/, $mac, 6);

    for (@a) {
    	s/^.$/0$&/;
    }

    join (":", @a);
}
