#!/usr/bin/perl -w

## TCP and UDP Portscan Monitor for use with "mon".

## Created:    <Fri Sep 22 15:42:41 EDT 2000>
## Time-stamp: <Sat Oct 07 14:33:45 EDT 2000>
## Author:     Alex Shinn <foof@debian.org>

## Copyright (C) 2000 Alex Shinn

## Sponsored by LinuxForce, http://www.LinuxForce.net/

## 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


# Modules
use strict;
use Socket;
use Sys::Hostname;
use Getopt::Long;

# Version info
my $version = '1.3';

# Options
my %opt;
GetOptions(\%opt, 'h', 'help', 'v', 'version', 't=s', 'tcp=s',
           'u=s', 'udp=s', 'x=s', 'exclude=s', 'exclude-tcp=s',
           'exclude-udp=s')
  or usage();

my %include_ports;
$include_ports{'tcp'} = $opt{'tcp'} || $opt{'t'} || '';
$include_ports{'udp'} = $opt{'udp'} || $opt{'u'} || '';
my $exclude = $opt{'exclude'} || $opt{'x'} || '';
my %exclude_ports;
$exclude_ports{'tcp'} = $opt{'exclude-tcp'} || '';
$exclude_ports{'udp'} = $opt{'exclude-udp'} || '';
$exclude_ports{'tcp'} .= ",$exclude";
$exclude_ports{'udp'} .= ",$exclude";
usage() if $opt{'help'} || $opt{'h'};
version() if $opt{'version'} || $opt{'v'};

# Other vars
my $maxport = 65535;
my $sleaze_port = 31337;
my $return_val = 0;
my %socket_type = ( 'tcp' => SOCK_STREAM, 'udp' => SOCK_DGRAM );
my $localip = inet_aton('localhost');
my $hostip = inet_aton(hostname());


# Default to scanning localhost
$ARGV[0] = 'localhost' unless @ARGV;

# Translate named ports
foreach my $proto ('tcp', 'udp') {
  $include_ports{$proto} =~ s{\b([a-zA-Z][-a-zA-Z0-9]*\b)}
                             { getservbyname($1, $proto) ||
                                 die "unknown port: $1" }egx;
  $exclude_ports{$proto} =~ s{\b([a-zA-Z][-a-zA-Z0-9]*\b)}
                             { getservbyname($1, $proto) ||
                                 die "unknown port: $1" }egx;
}

# For each host
foreach my $host (@ARGV) {
  # Get the IP address for the host
  my $ipaddr = inet_aton($host);
  if (not $ipaddr) {
    warn "no host: $host";
    next;
  }
  # Check TCP & UDP
  foreach my $protoname ('tcp', 'udp') {
    my $proto = getprotobyname($protoname);
    # Check non-excluded ports from 1-$maxport
    my $i = next_include(0, $include_ports{$protoname},
                         $exclude_ports{$protoname});
    while ($i <= $maxport) {
      # Check if the port should be open
      if (is_in_range($i, $include_ports{$protoname})) {
        if (! port_is_open($i, $ipaddr, $protoname)) {
          closed_port_error($host, $i, $protoname);
        }
      }
      # Otherwise, check if we should scan the port
      elsif (! is_in_range($i, $exclude_ports{$protoname})) {
        # ... and if so make sure it's closed
        if (port_is_open($i, $ipaddr, $protoname)) {
          open_port_error($host, $i, $protoname);
        }
      }
      $i = next_include($i, $include_ports{$protoname},
                        $exclude_ports{$protoname});
    }
  }
}

# Return the final result
exit $return_val;


### subroutines ###

# Port checking

sub port_is_open {
  # Args: port, ipaddr, proto
  my $port = shift || die "not enough args passed to port_is_open";
  my $ipaddr = shift || $localip
    || die "port_is_open: no host";
  my $protoname = shift || 'tcp';
  my $proto = getprotobyname($protoname)
    || die "couldn't getprotobyname: $protoname";
  # Get port and port address
  my $portaddr = sockaddr_in($port, $ipaddr);
  # Create a socket and test the connection
  socket(SOCK, PF_INET, $socket_type{$protoname}, $proto) || die "socket: $!";
  # Establishing a connection is good enough for TCP...
  my $result = connect(SOCK, $portaddr);
  # UDP is a little trickier.  This is a transliteration of the "tcp
  # ping" trick used by netcat.  Basically, try to write once, wait
  # the length of time it takes to establish a tcp connection, then
  # try to write a second time.  Not sure why this works, but it does.
  if ($protoname eq 'udp') {
    # Try to write once
    $result = syswrite(SOCK, 'x', 1);
    # Waste time
    if (($ipaddr eq $localip) or ($ipaddr eq $hostip)) {
      # For the local machine, the netcat technique works...
      socket(TEMP_SOCK, PF_INET, SOCK_STREAM, getprotobyname('tcp'));
      connect(TEMP_SOCK, $sleaze_port);
      close(TEMP_SOCK);
    }
    else {
      # ... but for remote machines the delay is not enough... so we
      # wait for a second.
      sleep(1);
    }
    # Try to write again
    $result = syswrite(SOCK, 'x', 1);
  }
  close(SOCK) || die "close: $!";
  return $result;
}

sub closed_port_error {
  my $host = shift || 'localhost';
  my $port = shift || '????';
  my $proto = shift || 'tcp';
  if ($return_val == 0) {
    $return_val = 1;
    print "$host: unexpected closed port(s) found\n";
  }
  print "$proto port $port on host $host was closed when expected to be open\n";
}

sub open_port_error {
  my $host = shift || 'localhost';
  my $port = shift || '????';
  my $proto = shift || 'tcp';
  if ($return_val == 0) {
    $return_val = 2;
    print "$host: unexpected open port(s) found\n";
  }
  print "$proto port $port on host $host was open when expected to be closed\n";
}

# Range handlers (should be generalized as a sparse integer range module)

# is_in_range($index, $range)
#   return 1 if $index falls within $range
sub is_in_range {
  my $i = shift;
  my $range = shift;
  if ($range =~ /\b$i\b/) {
    return 1;
  } else {
    while ($range =~ /([0-9]+)-([0-9]+)/g) {
      if (($1 <= $i) && ($i <= $2)) {
        return 1;
      }
    }
  }
  return 0;
}

# get_end_range($index, $range)
#   If $index falls within a subrange of $range, return the end of
#   that # range, otherwise return $index back.
sub get_end_range {
  my $index = shift;
  my $range = shift;
  while ($range =~ /([0-9]+)-([0-9]+)/g) {
    if (($1 <= $index) && ($index <= $2)) {
      return $2;
    }
  }
  return $index;
}

# next_include($current, $includes, $excludes)
#   return the next number that needs to be included
sub next_include {
  my $current = shift;
  my $includes = shift;
  my $excludes = shift;
  my $next = get_end_range($current, $excludes);
  # Match individual numbers
  while ($includes =~ /\b([0-9]+)\b/g) {
    if (($current <= $1) && ($1 <= $next)) {
      $next = $1;
    }
  }
  # Match ranges
  while ($includes =~ /\b([0-9]+)-([0-9]+)\b/g) {
    if (($1 <= $next) && ($current <= $2)) {
      $next = $current;
    }
  }
  # Correct Obi-Wan errors
  $next++ if ($next == $current);
  return $next;
}


# Information

# Print a usage summary

sub usage {
  (my $name = $0) =~ s/.*\/([^\/]*)/$1/;
  print <<EOF;
usage: $name [options] [hosts]

  -h, --help               display this message
  -v, --version            print version info
  -t, --tcp <ports>        tcp ports expected to be open
  -u, --udp <ports>        udp ports expected to be open
  -x, --exclude <ports>    ports to exclude from scan
  --exclude-tcp <ports>    tcp ports to exclude from scan
  --exclude-udp <ports>    udp ports to exclude from scan

where <ports> is a comma delimited list of integers from 1 to 65535,
or named ports, or ranges of integers or port names of the form
<start>-<end>.

If no hosts are specified, defaults to localhost.

Example:  $name -t 21-23,smtp,80 -u 7,9,13,19 -x 10000-65535
EOF
  exit 0;
}


# Print version info

sub version {
  (my $name = $0) =~ s/.*\/([^\/]*)/$1/;
  print "$name $version\n";
  exit 0;
}
