#!/usr/bin/perl

=head1 NAME

subnet-grep.pl

=head1 DESCRIPTION:

Script to search text for ip address on a given subnet

=head1 USAGE

 subnet-grep.pl [-c] [-v] [-q] <-f pattern-file | subnet/netmask> file [file...]
 subnet/mask can be specified as x.x.x.x/x.x.x.x or x.x.x.x/xx

=head1 REQUIRED ARGUMENTS

 subnet/mask
 	OR
 -f pattern-file

=head1 OPTIONS

 similar to grep:
 -c count matching lines
 -v select lines which do not match
 -f <pattern file> containing one subnet in the form above per line
 -q supress all output

=head1 EXIT STATUS

  0 on success/match
 >0 on no match or error

=head1 EXAMPLES

 ./subnet-grep.pl 10.5.1.0/24 /etc/hosts
 ./subnet-grep.pl -c 192.168.1.0/255.255.255.0 input1.txt input2.txt

=head1 BUGS AND LIMITATIONS

IPv4 addresses only

=head1 AUTHOR

Jarett Stevens (jarett.stevens@gmail.com)

=cut 

# incorporated feedback from http://perlmonks.org/?node_id=878676

use strict ;
use warnings ;
use Getopt::Std ;

our($opt_c, $opt_v, $opt_f, $opt_q) ;

my ($INFILE, $SUBNET, $NETMASK, $SUBNET_DEC, $NETMASK_DEC, %SUBNETS, %SUBNETS_DEC, %FILE_STATS)  ;

# defaults; print matching lines
my $PRINT_MATCH = 1 ;
my $PRINT_NOMATCH = 0 ;
my $MATCH_COUNT_TYPE = 'MATCH_COUNT' ;
my $RETURN_CODE = 1 ;

# converts the passed dotted-decimal ip string to an integer
# $_[0] = valid ip address
sub iptodec {
	# http://netfactory.dk/2007/10/02/ip-address-conversion-with-perl/
	return unpack N => pack CCCC => split /\./x => shift;
}

# matches valid (mostly) ip addresses
# $_[0] = string to validate as an ip
sub ipvalid {
	my ($IP) = @_ ;

	# http://www.regular-expressions.info/regexbuddy/ipaccurate.html
	return(1) if ( $IP =~ m/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/x ) ;
	return(0) ;
}

# matches IP addresses for a given subnet range on the passed file descriptor 
# $_[0] = file descriptor
# $_[1] = file name
sub subnet_grep  {

	my ($IN_LINES, $FNAME) = @_ ;

	${FILE_STATS}{$FNAME}{MATCH_COUNT} = 0 ;
	${FILE_STATS}{$FNAME}{NOMATCH_COUNT} = 0 ;

	INLINE:
	while ( my $LINE = <$IN_LINES> ) { 
		# less restrictive match than ipvalid() (no anchors)
		if ( $LINE =~ m/(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/x) {

			# we operate on a copy in order to keep $LINE unmodified for printing later
			my $IP_LINE = $LINE ;

			# remove non-ip-address characters, trim, break into words to validate
			$IP_LINE =~ tr/[0-9.]/ /c ;
			my @WORDS = split(' ', $IP_LINE) ;

			for my $THIS_WORD (@WORDS) {

				# if the string is an ip
				if (ipvalid($THIS_WORD)) {

					# try to match it against all the subnet patterns
					for my $SUBNET_PATTERN ( keys %SUBNETS_DEC ) {

						# if it matches
						if ( (iptodec($THIS_WORD) & ${SUBNETS_DEC}{$SUBNET_PATTERN}) == $SUBNET_PATTERN ) {

							${FILE_STATS}{$FNAME}{MATCH_COUNT}++ ;

							if ( $PRINT_MATCH ) {
								print "$FNAME:" if ($#ARGV > 0) ;
								print "$LINE" ;
							}

							# once the line matches, we go to the next one (or stop processing if -q)

							if ($opt_q && ! $opt_v) {
								#Set the return code to indicate a match
								$RETURN_CODE = 0 ;
								last ;
							}

							next INLINE ;
						}
					}
				}
			}
		}

		# only lines that don't match get here
		${FILE_STATS}{$FNAME}{NOMATCH_COUNT}++ ;

		if ( $PRINT_NOMATCH ) {
				print "$FNAME:" if ($#ARGV > 0) ;
				print "$LINE" ;
		}

		if ($opt_q && $opt_v) {
			$RETURN_CODE = 0 ;
			last ;
		}
	}

	return ;
}

#print a count of matching lines
#$_[0] = file name
sub print_count {

	my ($FNAME) = @_ ;

	if ( $#ARGV > 0 ) {
		print $FNAME, ":", ${FILE_STATS}{$FNAME}{$MATCH_COUNT_TYPE}, "\n" ;
	} else {
		# if we have only one file, we don't print it's name (could also be stdin)
		print ${FILE_STATS}{$FNAME}{$MATCH_COUNT_TYPE}, "\n" ;
	}

	return ;
}

# execution begins here
# parse command line
getopts('cvqf:') ;
die("Usage: $0 [-c] [-v] <-f pattern-file | subnet/netmask> file [file...]\n") unless ( @ARGV || defined $opt_f ) ;

# invert matching (print non-matching lines)
if (defined $opt_v) {
	$PRINT_MATCH = 0 ;
	$PRINT_NOMATCH = 1 ;
	$MATCH_COUNT_TYPE = 'NOMATCH_COUNT' ;
}

# only count the lines
if (defined $opt_c) {
	$PRINT_MATCH = 0 ;
	$PRINT_NOMATCH = 0 ;
}

# be completely quiet 
if (defined $opt_q) {
	$PRINT_MATCH = 0 ;
	$PRINT_NOMATCH = 0 ;
}
	

# if we're given a pattern file
if ( defined $opt_f ) {
	open('PATTERN_FILE', "<", "$opt_f") or die("Cannot read pattern file: $opt_f\n") ;
		my @PATTERN_FILE = <PATTERN_FILE> ;
	close(PATTERN_FILE) ;

	# load the IP/netmask pairs
	for (@PATTERN_FILE) {
		chomp($_) ;
		if ( $_ !~ m/^[0-9.]+\/[0-9.]+$/x ) {
			warn "Invalid pattern: $_\n" ;
			next ;
		}
		($SUBNET, $NETMASK) = split(/\//x, $_, 2) ;
		${SUBNETS}{$SUBNET} = $NETMASK ;
	}

# a single pattern is specified on the command line
} else {
	($SUBNET, $NETMASK) = split(/\//x, $ARGV[0], 2) ;
	${SUBNETS}{$SUBNET} = $NETMASK ;
	shift @ARGV ;
}

# the ip/netmask patterns are converted to decimal equivalents for matching
for ( keys %SUBNETS ) {

	unless (ipvalid($_)) {
		warn "Invalid ip: $_\n" ;
		next ;
	}

	$SUBNET_DEC = iptodec($_) ;

	# if the netmask is not an ip, it is a bit mask
	if ( ${SUBNETS}{$_} !~ m/\./x ) { 
		unless (${SUBNETS}{$_} =~ m/^[0-9]+$/x && ${SUBNETS}{$_} > 7 && ${SUBNETS}{$_} < 33) {
			warn "Invalid bitmask: ", ${SUBNETS}{$_}, "\n" ;
			next ;
		}

		# bitmask to decimal conversion
		$NETMASK_DEC = (2**${SUBNETS}{$_} - 1) << (32 - ${SUBNETS}{$_}) ;

	# the netmask is an ip
	} else { 
		unless(ipvalid(${SUBNETS}{$_})) {
			warn "Invalid netmask: ", ${SUBNETS}{$_}, "\n" ;
			next ;
		}
	
		$NETMASK_DEC = iptodec(${SUBNETS}{$_}) ;
	}

	# load the converted pair into the hash of patterns (ranges)
	${SUBNETS_DEC}{$SUBNET_DEC} = $NETMASK_DEC ;
}

die("No valid patterns\n") unless ((scalar keys %SUBNETS_DEC) > 0) ;

# if we have some arguments left after parsing options, they must be files
if ( @ARGV ) {

	# grep each file
	for my $FNAME (@ARGV) {

		open ($INFILE, "<", "$FNAME") or do { 
			warn "cannot open: $FNAME: $!\n" ;
			next ;
		} ;

		subnet_grep($INFILE, $FNAME) ;
		close($INFILE) ;

		print_count($FNAME) if ( defined  $opt_c ) ;
	}

# if we have no arguments left after parsing options, grep stdin
} else {
	subnet_grep('STDIN', "STDIN") ;
	print_count("STDIN") if ( defined  $opt_c ) ;
}

exit($RETURN_CODE) ;
