#! /bin/bash
#
# -- smtp-conversation-0.5, 2006-06-10; Martin A. Brown <martin@linux-ip.net>
#
# -- released under the GPL
#
# -- initiate an SMTP conversation with a remote host, and send some
#    data through the server.
#
# -- ChangeLog
#
#   0.51 2006-09-09; -MAB
#        package as an RPM; add Makefile
#    0.3 2006-05-19; -MAB
#        revise --long-usage output 
#    0.2 2006-05-19; -MAB
#        add bind option, modify short_usage and long_usage output
#        eval "doalarm" into place if binary doesn't exist
#        restructure main loop to distinguish between TCP connection
#          failures and SMTP server failures
#        provide more specific error-reporting (TCP? route? 5xx SMTP?)
#        supplant mimencode with the more common uuencode
#    0.1 2005-11-22; -MAB
#        first cleaned-up version based on older work
#

# -- Contributors
#
#    Mike Pomraning <mjp@pilcrow.madison.wi.us>  (doalarm + doalarm eval)

# -- BUGS/TODO
#
#    - Sort of silly that this relies on socat.  It probably wouldn't
#      be too hard to select between any of a number of different TCP
#      utilities, but error checking might get harder.
#    - use expect in the conversation instead of the rather unexciting
#      cat?
#

# -- script global variables, accept overridden envars
#
: "${SMTPCONV_SENDER:=${USER}@$( hostname --long )}"
: "${SMTPCONV_MSGCOUNT:=1}"
: "${SMTPCONV_PREPEND:=SMTPTEST}"
: "${SMTPCONV_TIMEOUT:=10}"
: "${SMTPCONV_INTERVAL:=60}"
: "${SMTPCONV_KILOBYTES:=0}"
: "${SMTPCONV_BINDADDR:=0.0.0.0}"

VERBOSE="${SMTPCONV_VERBOSE:-0}"
MODE=loop

# -- script global variables
#
INFORM=1
NOTICE=2
DEBUG=3

STDOUT=1
STDERR=2

VERSION=0.51
NAME=smtp-conversation

REQUIRED_UTILITIES="socat perl dd uuencode sed cat sleep awk"


# -- my favorite shell functions (and their offspring)
#
# - - - - - - - - - 
  gripe () {
# - - - - - - - - - 
#
  local descriptor=$1   && shift
  printf -- "%s\n" "$@" >&${!descriptor}
}

abort  () { gripe STDERR "$@"; exit 1;     }

inform () { test "$VERBOSE" -ge "$INFORM" && gripe STDERR "I: $@"; }
notice () { test "$VERBOSE" -ge "$NOTICE" && gripe STDERR "N: $@"; }
debug  () { test "$VERBOSE" -ge "$DEBUG"  && gripe STDERR "D: $@"; }

# - - - - - - - - - - -
  version  () {
# - - - - - - - - - - -
#
  gripe STDOUT "${0##*/}-${VERSION}"
  exit 0
}

# - - - - - - - - - - -
  usage  () {
# - - - - - - - - - - -
#
  local descriptor=$1   && shift
  cat >&${!descriptor} <<-EOUSAGE
Usage: ${0##*/} [ options ] -r <recip> <server> [ <server> ... ]

Required option:
  -r, --recipient          Intended recipient address. (required)

Options (defaults marked, or in parentheses):
  -q, --quiet              Reset verbosity to none.
  -v, --verbose            Be verbose. (interactive default)
  -u, --until-success      Loop until successful reaching server(s).
  -s, --sender             Sender address to use. (\$USER@\$HOST_FQDN)
  -c, --count              How many to send. ($SMTPCONV_MSGCOUNT)
  -k, --kilobytes          How much padding to include. ($SMTPCONV_KILOBYTES)
  -m, --message            Text to prepend to subject line. ("$SMTPCONV_PREPEND")
  -t, --timeout            Timeout while waiting for 250 OK. ($SMTPCONV_TIMEOUT)
  -i, --interval           Interval in seconds between messages. ($SMTPCONV_INTERVAL)
  -b, --bind <address>     Select address for outgoing connection. (INADDR_ANY)

EOUSAGE
}

# - - - - - - - - - - -
  short_usage  () {
# - - - - - - - - - - -
#
  local descriptor="${1:-STDOUT}"  && shift
  usage $descriptor
  test "$#" -gt 0 && abort "$@"
  exit 0
}

# - - - - - - - - - - -
  long_usage  () {
# - - - - - - - - - - -
#
  local descriptor="${1:-STDOUT}"  && shift
  cat >&${!descriptor} <<-EOUSAGE
${0##*/}-${VERSION} README

Description
===========
${0##*/} is useful for testing SMTP server reachability.

It can operate in two primary modes.  In default mode (loop mode), it
loops through the specified set of target servers, attempting to send
test messages.  By default it only sends a single test message, but
this can be controlled by the --count option.  In its alternate mode,
${9##*/} will attempt to send test messages to all SMTP servers until
it has succesfully sent a message through each target server.  This
mode can be switched on with the --until-success option.

This utility accepts an arbitrary number of <server> arguments.
Sadly, there is no granularity of control over the timeouts.  There
is one timeout value for everything from the TCP SYN to '250 OK'.

The tool is intended strictly for diagnostic purposes, and error
reporting makes a best effort to determine whether the problem was
a network layer problem or an SMTP layer problem.  Obviously, this
is only one very small part of mail troubleshooting.  A zero-padded
serial number is included in every subject line and body to assist
diagnostic efforts.

Message bodies will normally be very short, but can be increased by
specifying a size in kilobytes (see options below).  The exact number
of bytes added in the padding routines may vary, but should approach
the target number of kilobytes.

Options
=======

 --recipient (-r):      REQUIRED.  Specify a single recipient address
                        which will be used in the envelope and RFC 822
                        headers.

 --count (-c):          Send this many email messages.  Has no meaning
                        in '--until-success' mode, and will be ignored.
 --bind (-b):           Initiate connections from the specified IP.
 --interval (-i):       Sleep this many seconds between the end of one
                        connection and the beginning of the next.
 --until-success (-u):  Keep looping until a message has been
                        successfully sent.
 --kilobytes (-k):      Pad the message with roughly this many kilobytes
                        of random ASCII data.
 --timeout (-t):        Only wait this long for the entire SMTP session
                        (from SYN to "250 OK").
 --message (-m):        Include this tag on the subject line.
 --sender (-s):         Use the specified address as the envelope sender
                        and RFC 822 From line.


Required software
=================

  - socat; a general purpose "cat" utility for files, file descriptors,
    sockets, FIFOs or pipes

    http://www.dest-unreach.org/socat/

    ${0##*/} uses socat for all network operations, and relies on the
    error reporting generated from socat to communicate with the user

  - Basic UN*X utilities: perl, sed, uuencode, dd, cat, sleep, sed


Recommended software
====================

  - doalarm; sets an ALRM signal and exec's a child; ALRM signals are
    inherited from parent process
    
    http://directory.fsf.org/sysadmin/misc/doalarm.html
    http://pilcrow.madison.wi.us/sw/doalarm-0.1.7.tgz

    ${0##*/} will use perl to set an ALRM if doalarm is not
    installed, but the FSF package is much more lightweight


Examples
========
Try to send a test message to test@domain.net through mail.domain.net
with as little STDERR as possible.

  ${0##*/} -q -r test@domain.net mail.domain.net

Try to send five messages of about 20k with an interval of two seconds.
Use 127.0.0.1 as the SMTP server and be as chatty as possible.

  ${0##*/} -k 20 -i 2 -c 5 -vvr test@domain.net 127.0.0.1

Send one message from spoofed@gmail.com to test@domain.net through
mx2.mail.yahoo.com.

  ${0##*/} -s spoofed@gmail.com -r test@domain.net mail4.mxsmtp.com

Keep trying to send a test message to test@domain.net through 
flaky.server.net, until it accepts message for test@domain.net.

  ${0##*/} -u -r test@domain.net flaky.server.net

EOUSAGE

  usage $descriptor

exit 0

}

# - - - - - - - - - - -
  parse_options () {
# - - - - - - - - - - -
#
  command getopt                    \
    --unquoted                      \
    --name "${0##*/}"               \
    --options "${OPTIONS}"          \
    --longoptions "${LONGOPTIONS}"  \
    -- "$@"
}

# - - - - - - - - - - -
  verify_support  () {
# - - - - - - - - - - -
#
# -- all we want to do is make sure that all of our support
# programs exist.
#
  local program
  local programs="$@"
  local N
  local ok=0           # -- return 0, if everything is OK!
  
  for program in $programs; do
    N=$( which "$program" 2>/dev/null )
    if test -z "$N"; then
      ok=1
      gripe STDERR "Could not locate necessary support program:  $program"
    fi
  done
  return $ok
}



# - - - - - - - - - - -
  pretty_serial () {
# - - - - - - - - - - -
#
  local places=$1   && shift
  local serial=$1   && shift
  printf "%0${places}d" "$serial"
}

# - - - - - - - - - - -
  local_and_utc_dates () {
# - - - - - - - - - - -
#
  printf "%s   (%s)\n" "$( date '+%F %T %Z' )" "$( date --utc '+%F %T %Z' )"
}

# - - - - - - - - - - -
  network_io () {
# - - - - - - - - - - -
#
  local server=$1        && shift
  local timeout=$1       && shift

  # -- the doalarm makes sure that socat doesn't take longer than the
  #    timeout period to connect to the remote server, and/or while
  #    waiting for the session to complete; send all output to STDOUT
  #
  doalarm $timeout \
    socat STDIO TCP4:${server}:25,crnl,bind="${BINDADDR}"
}

# - - - - - - - - - - -
  padding_text () {
# - - - - - - - - - - -
#
  local size=$1         && shift
  test "$size" -lt 1    && return

  cat <<-EOPADDING
	<-=-=-=-=-    Padding text begins                 -=-=-=-=->
	
	$( dd 2>/dev/null if=/dev/urandom count=$size bs=755 \
	    | uuencode -m - | sed -ne '/====/d; 2,$p' )
	
	<-=-=-=-=-    Padding text ends                   -=-=-=-=->
	
EOPADDING
}

# - - - - - - - - - - -
  smtp_conversation () {
# - - - - - - - - - - -
#
  local sender="$1"       && shift
  local recip="$1"        && shift
  local server="$1"       && shift
  local serial="$1"       && shift
  local size="$1"         && shift

  local subject="$PREPEND [$serial]: server $server -> recip $recip"


  cat <<-EOSMTP
	HELO ${SRCHOST}
	MAIL FROM:<${sender}>
	RCPT TO:<${recip}>
	DATA
	From: ${sender}
	To: ${recip}
	Date: $( date --rfc-822 )
	Subject: ${subject}
	
	This is a test message generated on $SRCHOST in order
	to test SMTP connectivity to $server.
	
	Attributes of this message:
	
	         serial: $serial
	          recip: $recip
	         sender: $sender
	    source host: $SRCHOST
	  server tested: $server
	      date sent: $( local_and_utc_dates )
	    subject tag: $PREPEND
	
	If you have any questions about this message, please contact the
	administrator of $SRCHOST and/or $sender.
	
	$( padding_text $size )
	.
EOSMTP
}


# -- check to make sure that we have a few key utilities before
#    launching off the command line processing
#
verify_support which \
  || abort "${0##*/}: Cannot proceed without \"which\" utility, quitting."

verify_support getopt \
  || abort "${0##*/}: Cannot process command line options, quitting."

# - - - - - - - - - - -
# main () {
# - - - - - - - - - - -
#
# -- normalize the command-line options
#
OPTIONS="vqhLVuc:s:r:m:t:k:i:b:"
LONGOPTIONS="verbose,quiet,help,long-usage,version"
LONGOPTIONS="$LONGOPTIONS,until-success,sender:,recipient:"
LONGOPTIONS="$LONGOPTIONS,message:,timeout:,kilobytes:,interval:,count:"
LONGOPTIONS="$LONGOPTIONS,bind:"

# -- calling parse_options twice seems silly.  It is.  It allows, us
#    however, to control the error reporting to the user a bit more
#    carefully.  All the OCD kids are doing it.
#
parse_options "$@" >/dev/null \
  || abort "Try \"${0##*/} --help\" for more information."

set -- $( parse_options "$@" )

# -- Bump up verbosity a notch if we are connected to a terminal;
#    generally, users like to know a bit more of what's going on...
#
tty --silent         && let VERBOSE=VERBOSE+1

# -- examples of options/arguments should look like this now:
# 
while test "$#" -gt "0" ; do
   case "$1" in

      -h | --h*    ) short_usage                              ;;
      -L | --l*    ) long_usage                               ;;
      -V | --vers* ) version                                  ;;
      -v | --verb* ) let VERBOSE=VERBOSE+1                    ;;
      -q | --q*    ) VERBOSE=0                                ;;

      -b | --b*    ) BINDADDR="$2"      && shift              ;;
      -m | --m*    ) PREPEND="$2"       && shift              ;;
      -c | --c*    ) MSGCOUNT="$2"      && shift              ;;
      -t | --t*    ) NETTIMEOUT="$2"    && shift              ;;
      -s | --s*    ) SENDER="$2"        && shift              ;;
      -i | --i*    ) INTERVAL="$2"      && shift              ;;
      -k | --k*    ) KILOBYTES="$2"     && shift              ;;
      -r | --r*    ) RECIPIENT="$2"     && shift              ;;
      -u | --u*    ) MODE="until"                             ;;

             --    ) shift              && break              ;;
             -*    ) short_usage "Unknown option: $1"         ;;

   esac
   shift
done

verify_support $REQUIRED_UTILITIES \
  || abort "${0##*/}: Missing utilities, cannot continue, quitting."

test -z "$RECIPIENT" \
  && short_usage STDERR "Recipient address is required."

# -- if doalarm is not installed, we'll create our own, using perl
#    thanks to MJP
#
verify_support 2>/dev/null doalarm \
  || eval 'doalarm () { perl -e "alarm \$ARGV[0] ; shift ; exec { \$ARGV[0] } @ARGV" "$@" ; }'

# -- command line arguments override environment variables
#
SENDER="${SENDER:-$SMTPCONV_SENDER}"
MSGCOUNT="${MSGCOUNT:-$SMTPCONV_MSGCOUNT}"
NETTIMEOUT="${NETTIMEOUT:-$SMTPCONV_TIMEOUT}"
INTERVAL="${INTERVAL:-$SMTPCONV_INTERVAL}"
KILOBYTES="${KILOBYTES:-$SMTPCONV_KILOBYTES}"
PREPEND="${PREPEND:-$SMTPCONV_PREPEND}"
BINDADDR="${BINDADDR:-$SMTPCONV_BINDADDR}"
SRCHOST=$( hostname --long )

# -- make sure the user specified at least one IP address
#
test $# -ge 1        || short_usage STDERR "No server(s) specified."

SERVERS="$@"
SERIAL=0

# -- main loop starts here
#
while let SERIAL=SERIAL+1 ; do

  notice "Loop counter now equals $SERIAL."

  for SERVER in $SERVERS ; do

    unset RETEST

    SMTP_SESSION_OUTPUT=$(

      smtp_conversation "$SENDER" "$RECIPIENT" "$SERVER"        \
        "$( pretty_serial ${#MSGCOUNT} $SERIAL )" "$KILOBYTES"  \
          | network_io 2>&1 "$SERVER" "$NETTIMEOUT"

                         )


    # -- first, we need to see if the network connect succeeded
    #    the "network_io" function will return socat's return code,
    #    so, if there were network problems, we'll see a nonzero
    #    return code
    #
    case "$?" in                    # -- beginning of network layer block


      142 ) MESS="$SERVER, SMTP not reachable on loop $SERIAL"
            DETAIL=" network timeout ($NETTIMEOUT seconds)"
            inform "${MESS}:${DETAIL}"

            test "$MODE" = "until" &&   RETEST="$RETEST $SERVER"    
            ;;

        1 ) MESS="$SERVER, SMTP not available on loop $SERIAL"
            DETAIL=$( printf "%s\n" "$SMTP_SESSION_OUTPUT" \
	                | awk -F: '{ print $5 }' )

            inform "${MESS}:${DETAIL}."

            test "$MODE" = "until" &&   RETEST="$RETEST $SERVER"    
            ;;

        0 ) # -- so, no we know that the TCP connection itself succeeded,
            #    and we'll examine the SMTP conversation output to see
            #    if there were application layer problems.
            #
            # -- This is assumption city, below; but should be adequate
            #    for a majority of troubleshooting situations.
            #
            SMTP_STATUS=$( printf "%s\n" "$SMTP_SESSION_OUTPUT" \
                             | cut -c1 | sort -nu | tail -1 )

            # -- if the highest SMTP_STATUS code is 2xx or 3xx, then
            #    everything went well (from the client's perspective)
            #    but if SMTP_STATUS code is 4xx or 5xx, then
            #    then, we have problems, and we will need to retest
            #    the SMTP server in "until-success" mode
            #
            case "$SMTP_STATUS" in 

               2 | 3 ) inform "$SERVER, SMTP alive on loop $SERIAL."         ;;
               4 | 5 ) inform "$SERVER, SMTP error on loop $SERIAL."
	               debug "$SMTP_SESSION_OUTPUT"
                       test "$MODE" = "until" && RETEST="$RETEST $SERVER"    ;;

            esac

    esac                            # -- end of network layer block

    debug "$SMTP_SESSION_OUTPUT"

  done

  # -- handle some mode-specific logic here....try to reset the list
  #    of servers required for testing
  #
  if test "$MODE" = "until" ; then
    SERVERS="$RETEST"
    if test -z "$SERVERS" ; then
      notice "No more servers to test, exiting."
      break
    fi
  fi

  # -- we are done if we have looped enough times in loop mode
  #
  if test "$SERIAL" -ge "$MSGCOUNT" -a "$MODE" = "loop" ; then
    notice "Reached $MSGCOUNT loops, exiting."
    break
  fi

  inform "Sleeping $INTERVAL seconds before connecting again."

  sleep $INTERVAL

done

debug "Outside of loop and done."

# -- end of file
