#! /bin/bash
#
# -- smtp-conversation-0.1, 2006-05-19; 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.2 2005-11-22; -MAB
#        add bind option, modify short_usage and long_usage output
#        provide more specific error-reporting to the user
#        eval "doalarm" into place if binary doesn't exist
#    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.
#

# -- script global variables, accept overridden envars
#
SMTPCONV_SENDER="${SMTPCONV_SENDER:-${USER}@$( hostname --long )}"
SMTPCONV_MSGCOUNT="${SMTPCONV_MSGCOUNT:-1}"
SMTPCONV_PREPEND="${SMTPCONV_PREPEND:-SMTPTEST}"
SMTPCONV_TIMEOUT="${SMTPCONV_TIMEOUT:-10}"
SMTPCONV_INTERVAL="${SMTPCONV_INTERVAL:-60}"
SMTPCONV_KILOBYTES="${SMTPCONV_KILOBYTES:-0}"
SMTPCONV_BINDADDR="${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.1
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
  usage $descriptor
  cat >&${!descriptor} <<-EOUSAGE
${0##*/}-${VERSION} README

Description
===========

This utility accepts any number of <server> arguments it will connect
to each <server> in turn to send a test message.  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 utility includes a zero-padded serial number in the subject and
body to assist in mail troubleshooting.  If <message> is specified,
it will be prepended to the subject line.  Messages will normally be
very short, however, you can make them longer by specifying a number
of <kilobytes> of random text to add for padding (actual amount of
padding may not be exactly <kilobytes> in size).

This utility will operate in two modes, loop mode or \"until\" mode.
In loop mode, it'll loop <count> times.  The <count> option has no
meaning in the \"until\" mode.  In this non-default mode, the utility
will continue to try to connect until the remote server is reachable,
at which time it'll stop.

Requirements
============

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

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

  - doalarm; though not strictly required (perl is used instead, if
    this utility is not found), doalarm is quite lightweight and
    convenient

    http://directory.fsf.org/sysadmin/misc/doalarm.html

Examples
========

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

Verbosely send (5) 20kb messages every two seconds to 127.0.0.1
  ${0##*/} -k 20 -i 2 -c 5 -vvr test@domain.net 127.0.0.1

Send one message from porc@gmail.com through mail4.mxsmtp.com.
  ${0##*/} -s porc@gmail.com -r test@domain.net mail4.mxsmtp.com

Wait until flaky.server.net accepts message for test@domain.net.
  ${0##*/} -u -r test@domain.net flaky.server.net


Options
=======



EOUSAGE

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" "$@" ; }'

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=" timeout ($NETTIMEOUT seconds)"
            inform "${MESS}:${DETAIL}"

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

        1 ) MESS="$SERVER, SMTP not reachable 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
