#!/bin/ash
#
# beardropper - dropbear log parsing ban agent for OpenWRT (Chaos Calmer rewrite of dropBrute.sh)
#   http://github.com/robzr/bearDropper  -- Rob Zwissler 11/2015
# 
#   - lightweight, no dependencies, busybox ash + native OpenWRT commands
#   - uses uci for configuration, overrideable via command line arguments
#   - runs continuously in background (via init script) or periodically (via cron)
#   - uses BIND time shorthand, ex: 1w5d3h1m8s is 1 week, 5 days, 3 hours, 1 minute, 8 seconds
#   - Whitelist IP or CIDR entries (TBD) in uci config file
#   - Records state file to tmpfs and intelligently syncs to persistent storage (can disable)
#   - Persistent sync routines are optimized to avoid excessive writes (persistentStateWritePeriod)
#   - Every run occurs in one of the following modes. If not specified, interval mode (24 hours) is 
#     the default when not specified (the init script specifies follow mode via command line)
# 
#     "follow" mode follows syslog to process entries as they happen; generally launched via init
#        script. Responds the fastest, runs the most efficiently, but is always in memory.
#     "interval" mode only processes entries going back the specified interval; requires 
#       more processing than today mode, but responds more accurately. Use with cron.
#     "today" mode looks at log entries from the day it is being run, simple and lightweight, 
#       generally run from cron periodically (same simplistic behavior as dropBrute.sh)
#     "entire" mode runs through entire contents of the syslog ring buffer
#     "wipe" mode tears down the firewall rules and removes the state files

# Load UCI config variable, or use default if not set
# Args: $1 = variable name (also uci option name), $2 = default_value
uciSection='beardropper.@[0]'
uciLoadVar () { 
  local getUci
  getUci=`uci -q get ${uciSection}."$1"` || getUci="$2" 
  eval $1=\'$getUci\'; 
}
uciLoad() {
  local tFile=`mktemp` delim="
"
  [ "$1" = -d ] && { delim="$2"; shift 2; }
  uci -q -d"$delim" get "$uciSection.$1" 2>/dev/null >$tFile
  if [ $? = 0 ] ; then
    sed -e s/^\'// -e s/\'$// <$tFile
  else
    while [ -n "$2" ]; do echo $2; shift; done
  fi
  rm -f $tFile
}

# Common config variables - edit these in /etc/config/beardropper
# or they can be overridden at runtime with command line options
#
uciLoadVar defaultMode entire
uciLoadVar enabled 0
uciLoadVar attemptCount 10
uciLoadVar attemptPeriod 12h
uciLoadVar banLength 1w
uciLoadVar logLevel 1
uciLoadVar logFacility authpriv.notice
uciLoadVar persistentStateWritePeriod -1
uciLoadVar fileStateType bddb
uciLoadVar fileStateTempPrefix /tmp/beardropper
uciLoadVar fileStatePersistPrefix /etc/beardropper
firewallHookChains="`uciLoad -d \  firewallHookChain input_wan_rule:1 forwarding_wan_rule:1`"
uciLoadVar firewallTarget DROP

# Not commonly changed, but changeable via uci or cmdline (primarily 
# to enable multiple parallel runs with different parameters)
uciLoadVar firewallChain beardropper

# Advanced variables, changeable via uci only (no cmdline), it is 
# unlikely that these will need to be changed, but just in case...
#
uciLoadVar syslogTag "beardropper[$$]"
# how often to attempt to expire bans when in follow mode
uciLoadVar followModeCheckInterval 30m	
uciLoadVar cmdLogread 'logread'		# for tuning, ex: "logread -l250"
uciLoadVar cmdLogreadEba 'logread'	# for "Exit before auth:" backscanning
uciLoadVar formatLogDate '%b %e %H:%M:%S %Y'	# used to convert syslog dates
uciLoadVar formatTodayLogDateRegex '^%a %b %e ..:..:.. %Y'	# filter for today mode

# Begin functions
#
# Clear bddb entries from environment
bddbClear () { 
  local bddbVar
  for bddbVar in `set | egrep '^bddb_[0-9_]*=' | cut -f1 -d= | xargs echo -n` ; do eval unset $bddbVar ; done
  bddbStateChange=1
}

# Returns count of unique IP entries in environment
bddbCount () { set | egrep '^bddb_[0-9_]*=' | wc -l ; }

# Loads existing bddb file into environment
# Arg: $1 = file, $2 = type (bddb/bddbz), $3 = 
bddbLoad () { 
  local loadFile="$1.$2" fileType="$2"
  if [ "$fileType" = bddb -a -f "$loadFile" ] ; then
    . "$loadFile"
  elif [ "$fileType" = bddbz -a -f "$loadFile" ] ; then
    local tmpFile="`mktemp`"
    zcat $loadFile > "$tmpFile"
    . "$tmpFile"
    rm -f "$tmpFile"
  fi
  bddbStateChange=0
}

# Saves environment bddb entries to file, Arg: $1 = file to save in
bddbSave () { 
  local saveFile="$1.$2" fileType="$2"
  if [ "$fileType" = bddb ] ; then
    set | egrep '^bddb_[0-9_]*=' | sed s/\'//g > "$saveFile"
  elif [ "$fileType" = bddbz ] ; then
    set | egrep '^bddb_[0-9_]*=' | sed s/\'//g | gzip -c > "$saveFile"
  fi
  bddbStateChange=0 
}

# Set bddb record status=1, update ban time flag with newest
# Args: $1=IP Address $2=timeFlag
bddbEnableStatus () {
  local record=`echo $1 | sed -e 's/\./_/g' -e 's/^/bddb_/'`
  local newestTime=`bddbGetTimes $1 | sed 's/.* //' | xargs echo $2 | tr \  '\n' | sort -n | tail -1 `
  eval $record="1,$newestTime"
  bddbStateChange=1
}

# Args: $1=IP Address
bddbGetStatus () {
  bddbGetRecord $1 | cut -d, -f1
}

# Args: $1=IP Address
bddbGetTimes () {
  bddbGetRecord $1 | cut -d, -f2-
}

# Args: $1 = IP address, $2 [$3 ...] = timestamp (seconds since epoch)
bddbAddRecord () {
  local ip="`echo "$1" | tr . _`" ; shift
  local newEpochList="$@" status="`eval echo \\\$bddb_$ip | cut -f1 -d,`"
  local oldEpochList="`eval echo \\\$bddb_$ip | cut -f2- -d,  | tr , \ `" 
  local epochList=`echo $oldEpochList $newEpochList | xargs -n 1 echo | sort -un | xargs echo -n | tr \  ,`
  [ -z "$status" ] && status=0
  eval "bddb_$ip"\=\"$status,$epochList\"
  bddbStateChange=1
}

# Args: $1 = IP address
bddbRemoveRecord () {
  local ip="`echo "$1" | tr . _`"
  eval unset bddb_$ip
  bddbStateChange=1
}

# Returns all IPs (not CIDR) present in records
bddbGetAllIPs () { 
  local ipRaw record
  set | egrep '^bddb_[0-9_]*=' | tr \' \  | while read record ; do
    ipRaw=`echo $record | cut -f1 -d= | sed 's/^bddb_//'`
    if [ `echo $ipRaw | tr _ \  | wc -w` -eq 4 ] ; then
      echo $ipRaw | tr _ .
    fi
  done
}

# retrieve single IP record, Args: $1=IP
bddbGetRecord () {
  local record
  record=`echo $1 | sed -e 's/\./_/g' -e 's/^/bddb_/'`
  eval echo \$$record
}

isValidBindTime () { echo "$1" | egrep -q '^[0-9]+$|^([0-9]+[wdhms]?)+$' ; }

# expands Bind time syntax into seconds (ex: 3w6d23h59m59s), Arg: $1=time string
expandBindTime () {
  isValidBindTime "$1" || { logLine 0 "Error: Invalid time specified ($1)" >&2 ; exit 254 ; }
  echo $((`echo "$1" | sed -e 's/w+*/*7d+/g' -e 's/d+*/*24h+/g' -e 's/h+*/*60m+/g' -e 's/m+*/*60+/g' \
    -e s/s//g -e s/+\$//`))
}

# Args: $1 = loglevel, $2 = info to log
logLine () {
  [ $1 -gt $logLevel ] && return
  shift
  if [ "$logFacility" = "stdout" ] ; then echo "$@"
  elif [ "$logFacility" = "stderr" ] ; then echo "$@" >&2
  else logger -t "$syslogTag" -p "$logFacility" "$@"
  fi
}

# extra validation, fails safe. Args: $1=log line
getLogTime () {
  local logDateString=`echo "$1" | sed -n \
    's/^[A-Z][a-z]* \([A-Z][a-z]*  *[0-9][0-9]*  *[0-9][0-9]*:[0-9][0-9]:[0-9][0-9] [0-9][0-9]*\) .*$/\1/p'`
  date -d"$logDateString" -D"$formatLogDate" +%s || logLine 1 \
    "Error: logDateString($logDateString) malformed line ($1)"
}

# extra validation, fails safe. Args: $1=log line
getLogIP () { 
  local logLine="$1"
  local ebaPID=`echo "$logLine" | sed -n 's/^.*authpriv.info \(dropbear\[[0-9]*\]:\) Exit before auth:.*/\1/p'`
  [ -n "$ebaPID" ] && logLine=`$cmdLogreadEba | fgrep "${ebaPID} Child connection from "`
  echo "$logLine" | sed -n 's/^.*[^0-9]\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p'
}

# Args: $1=IP
unBanIP () {
  if iptables -C $firewallChain -s $ip -j "$firewallTarget" 2>/dev/null ; then
    logLine 1 "Removing ban rule for IP $ip from iptables"
    iptables -D $firewallChain -s $ip -j "$firewallTarget"
  else
    logLine 3 "unBanIP() Ban rule for $ip not present in iptables"
  fi
}

# Args: $1=IP
banIP () {
  local ip="$1" x chain position
  if ! iptables -nL $firewallChain >/dev/null 2>/dev/null ; then  
    logLine 1 "Creating iptables chain $firewallChain"
    iptables -N $firewallChain
  fi
  for x in $firewallHookChains ; do
    chain="${x%:*}" ; position="${x#*:}"
    if [ $position -ge 0 ] &&  ! iptables -C $chain -j $firewallChain 2>/dev/null ; then
      logLine 1 "Inserting hook into iptables chain $chain"
      if [ $position = 0 ] ; then
        iptables -A $chain -j $firewallChain
      else
        iptables -I $chain $position -j $firewallChain
    fi ; fi 
  done
  if ! iptables -C $firewallChain -s $ip -j "$firewallTarget" 2>/dev/null ; then
    logLine 1 "Inserting ban rule for IP $ip into iptables chain $firewallChain"
    iptables -A $firewallChain -s $ip -j "$firewallTarget"
  else
    logLine 3 "banIP() rule for $ip already present in iptables chain"
  fi
}

wipeFirewall () {
  local x chain position
  for x in $firewallHookChains ; do
    chain="${x%:*}" ; position="${x#*:}"
    if [ $position -ge 0 ] ; then
      if iptables -C $chain -j $firewallChain 2>/dev/null ; then
        logLine 1 "Removing hook from iptables chain $chain"
        iptables -D $chain -j $firewallChain
    fi ; fi
  done
  if iptables -nL $firewallChain >/dev/null 2>/dev/null ; then  
    logLine 1 "Flushing and removing iptables chain $firewallChain"
    iptables -F $firewallChain 2>/dev/null
    iptables -X $firewallChain 2>/dev/null
  fi
}

# review state file for expired records - we could add the bantime to
# the rule via --comment but I can't think of a reason why that would
# be necessary unless there is a bug in the expiration logic. The
# state db should be more resiliant than the firewall in practice.
#
bddbCheckStatusAll () {
  local now=`date +%s`
  bddbGetAllIPs | while read ip ; do
    if [ `bddbGetStatus $ip` -eq 1 ] ; then
      logLine 3 "bddbCheckStatusAll($ip) testing banLength:$banLength + bddbGetTimes:`bddbGetTimes $ip` vs. now:$now"
      if [ $((banLength + `bddbGetTimes $ip`)) -lt $now ] ; then
        logLine 1 "Ban expired for $ip, removing from iptables"
        unBanIP $ip
        bddbRemoveRecord $ip
      else 
        logLine 3 "bddbCheckStatusAll($ip) not expired yet"
        banIP $ip
      fi
    elif [ `bddbGetStatus $ip` -eq 0 ] ; then
      local times=`bddbGetTimes $ip | tr , \ `
      local timeCount=`echo $times | wc -w`
      local lastTime=`echo $times | cut -d\  -f$timeCount`
      if [ $((lastTime + attemptPeriod)) -lt $now ] ; then
        bddbRemoveRecord $ip
    fi ; fi
    saveState
  done
  loadState
}

# Only used when status is already 0 and possibly going to 1, Args: $1=IP
bddbEvaluateRecord () {
  local ip=$1 firstTime lastTime
  local times=`bddbGetRecord $1 | cut -d, -f2- | tr , \ `
  local timeCount=`echo $times | wc -w`
  local didBan=0
  
  # 1: not enough attempts => do nothing and exit
  # 2: attempts exceed threshold in time period => ban
  # 3: attempts exceed threshold but time period is too long => trim oldest time, recalculate
  while [ $timeCount -ge $attemptCount ] ; do
    firstTime=`echo $times | cut -d\  -f1`
    lastTime=`echo $times | cut -d\  -f$timeCount`
    timeDiff=$((lastTime - firstTime))
    logLine 3 "bddbEvaluateRecord($ip) count=$timeCount timeDiff=$timeDiff/$attemptPeriod"
    if [ $timeDiff -le $attemptPeriod ] ; then
      bddbEnableStatus $ip $lastTime
      logLine 2 "bddbEvaluateRecord($ip) exceeded ban threshold, adding to iptables"
      banIP $ip
      didBan=1
    fi
    times=`echo $times | cut -d\  -f2-`
    timeCount=`echo $times | wc -w`
  done  
  [ $didBan = 0 ] && logLine 2 "bddbEvaluateRecord($ip) does not exceed threshhold, skipping"
}

# Reads filtered log line and evaluates for action  Args: $1=log line
processLogLine () {
  local time=`getLogTime "$1"` 
  local ip=`getLogIP "$1"` 
  local status="`bddbGetStatus $ip`"

  if [ "$status" = -1 ] ; then
    logLine 2 "processLogLine($ip,$time) IP is whitelisted"
  elif [ "$status" = 1 ] ; then
    if [ "`bddbGetTimes $ip`" -ge $time ] ; then
      logLine 2 "processLogLine($ip,$time) already banned, ban timestamp already equal or newer"
    else
      logLine 2 "processLogLine($ip,$time) already banned, updating ban timestamp"
      bddbEnableStatus $ip $time
    fi
    banIP $ip
  elif [ -n "$ip" -a -n "$time" ] ; then
    bddbAddRecord $ip $time
    logLine 2 "processLogLine($ip,$time) Added record, comparing"
    bddbEvaluateRecord $ip 
  else
    logLine 1 "processLogLine($ip,$time) malformed line ($1)"
  fi
}

# Args, $1=-f to force a persistent write (unless lastPersistentStateWrite=-1)
saveState () {
  local forcePersistent=0
  [ "$1" = "-f" ] && forcePersistent=1

  if [ $bddbStateChange -gt 0 ] ; then
    logLine 3 "saveState() saving to temp state file"
    bddbSave "$fileStateTempPrefix" "$fileStateType"
    logLine 3 "saveState() now=`date +%s` lPSW=$lastPersistentStateWrite pSWP=$persistentStateWritePeriod fP=$forcePersistent"
  fi    
  if [ $persistentStateWritePeriod -gt 1 ] || [ $persistentStateWritePeriod -eq 0 -a $forcePersistent -eq 1 ] ; then
    if [ $((`date +%s` - lastPersistentStateWrite)) -ge $persistentStateWritePeriod ] || [ $forcePersistent -eq 1 ] ; then
      if [ ! -f "$fileStatePersist" ] || ! cmp -s "$fileStateTemp" "$fileStatePersist" ; then
        logLine 2 "saveState() writing to persistent state file"
        bddbSave "$fileStatePersistPrefix" "$fileStateType"
        lastPersistentStateWrite="`date +%s`"
  fi ; fi ; fi
}

loadState () {
  bddbClear
  bddbLoad "$fileStatePersistPrefix" "$fileStateType"
  bddbLoad "$fileStateTempPrefix" "$fileStateType"
  logLine 2 "loadState() loaded `bddbCount` entries"
}

printUsage () {
  cat <<-_EOF_
	Usage: beardropper [-m mode] [-a #] [-b #] [-c ...] [-C ...] [-f ...] [-l #] [-j ...] [-p #] [-P #] [-s ...]

	  Running Modes (-m) (def: $defaultMode)
	    follow     constantly monitors log
	    entire     processes entire log contents
	    today      processes log entries from same day only
	    #          interval mode, specify time string or seconds
	    wipe       wipe state files, unhook and remove firewall chain

	  Options
	    -a #   attempt count before banning (def: $attemptCount)
	    -b #   ban length once attempts hit threshold (def: $banLength)
	    -c ... firewall chain to record bans (def: $firewallChain)
	    -C ... firewall chains/positions to hook into (def: $firewallHookChains)
	    -f ... log facility (syslog facility or stdout/stderr) (def: $logFacility)
	    -j ... firewall target (def: $firewallTarget)
	    -l #   log level - 0=off, 1=standard, 2=verbose (def: $logLevel)
	    -p #   attempt period which attempt counts must happen in (def: $attemptPeriod)
	    -P #   persistent state file write period (def: $persistentStateWritePeriod)
	    -s ... persistent state file prefix (def: $fileStatePersistPrefix)
	    -t ... temporary state file prefix (def: $fileStateTempPrefix)

	  All time strings can be specified in seconds, or using BIND style
	  time strings, ex: 1w2d3h5m30s is 1 week, 2 days, 3 hours, etc...

	_EOF_
}

#  Begin main logic
#
unset logMode
while getopts a:b:c:C:f:hj:l:m:p:P:s:t: arg ; do
  case "$arg" in 
    a) attemptCount="$OPTARG" ;;
    b) banLength="$OPTARG" ;;
    c) firewallChain="$OPTARG" ;;
    C) firewallHookChains="$OPTARG" ;;
    f) logFacility="$OPTARG" ;;
    j) firewallTarget="$OPTARG" ;;
    l) logLevel="$OPTARG" ;;
    m) logMode="$OPTARG" ;;
    p) attemptPeriod="$OPTARG" ;;
    P) persistentStateWritePeriod="$OPTARG" ;;
    s) fileStatePersistPrefix="$OPTARG" ;;
    s) fileStatePersistPrefix="$OPTARG" ;;
    *) printUsage
      exit 254
  esac
  shift `expr $OPTIND - 1`
done
[ -z $logMode ] && logMode="$defaultMode"

fileStateTemp="$fileStateTempPrefix.$fileStateType"
fileStatePersist="$fileStatePersistPrefix.$fileStateType"

attemptPeriod=`expandBindTime $attemptPeriod`
banLength=`expandBindTime $banLength`
[ $persistentStateWritePeriod != -1 ] && persistentStateWritePeriod=`expandBindTime $persistentStateWritePeriod`
followModeCheckInterval=`expandBindTime $followModeCheckInterval`
exitStatus=0

# Here we convert the logRegex list into a sed -f file
fileRegex="/tmp/beardropper.$$.regex"
uciLoad logRegex 's/[`$"'\\\'']//g' '/has invalid shell, rejected$/d' \
  '/^[A-Za-z ]+[0-9: ]+authpriv.warn dropbear\[.+([0-9]+\.){3}[0-9]+/p' \
  '/^[A-Za-z ]+[0-9: ]+authpriv.info dropbear\[.+:\ Exit before auth:.*/p' > "$fileRegex"
lastPersistentStateWrite="`date +%s`"
loadState
bddbCheckStatusAll

# main event loops

if [ "$logMode" = follow ] ; then 
  logLine 1 "Running in follow mode"
  readsSinceSave=0 lastCheckAll=0 worstCaseReads=1 tmpFile="/tmp/beardropper.$$.1"
# Verify if these do any good - try saving to a temp.  Scope may make saveState useless.
  trap "rm -f "$tmpFile" "$fileRegex" ; exit " SIGINT
  [ $persistentStateWritePeriod -gt 1 ] && worstCaseReads=$((persistentStateWritePeriod / followModeCheckInterval))
  firstRun=1
  $cmdLogread -f | while read -t $followModeCheckInterval line || true ; do
    if [ $firstRun -eq 1 ] ; then
      trap "saveState -f" SIGHUP
      trap "saveState -f; exit" SIGINT
      firstRun=0
    fi
    sed -nEf "$fileRegex" > "$tmpFile" <<-_EOF_
	$line
	_EOF_
    line="`cat $tmpFile`"
    [ -n "$line" ] && processLogLine "$line"
    logLine 3 "ReadComp:$readsSinceSave/$worstCaseReads"
    if [ $((++readsSinceSave)) -ge $worstCaseReads ] ; then
      now="`date +%s`"
      if [ $((now - lastCheckAll)) -ge $followModeCheckInterval ] ; then
        bddbCheckStatusAll
        lastCheckAll="$now"
        saveState
        readsSinceSave=0
      fi
    fi
  done
elif [ "$logMode" = entire ] ; then 
  logLine 1 "Running in entire mode"
  $cmdLogread | sed -nEf "$fileRegex" | while read line ; do 
    processLogLine "$line" 
    saveState
  done
  loadState
  bddbCheckStatusAll
  saveState -f
elif [ "$logMode" = today ] ; then 
  logLine 1 "Running in today mode"
  # merge the egrep into sed with -e /^$formatTodayLogDateRegex/!d
  $cmdLogread | egrep "`date +\'$formatTodayLogDateRegex\'`" | sed -nEf "$fileRegex" | while read line ; do 
      processLogLine "$line" 
      saveState
    done
  loadState
  bddbCheckStatusAll
  saveState -f
elif isValidBindTime "$logMode" ; then
  logInterval=`expandBindTime $logMode`
  logLine 1 "Running in interval mode (reviewing $logInterval seconds of log entries)..."
  timeStart=$((`date +%s` - logInterval))
  $cmdLogread | sed -nEf "$fileRegex" | while read line ; do
    timeWhen=`getLogTime "$line"`
    [ $timeWhen -ge $timeStart ] && processLogLine "$line"
    saveState
  done
  loadState
  bddbCheckStatusAll
  saveState -f
elif [ "$logMode" = wipe ] ; then 
  logLine 2 "Wiping state files, unhooking and removing iptables chains"
  wipeFirewall
  if [ -f "$fileStateTemp" ] ; then
    logLine 1 "Removing non-persistent statefile ($fileStateTemp)"
    rm -f "$fileStateTemp"
  fi
  if [ -f "$fileStatePersist" ] ; then
    logLine 1 "Removing persistent statefile ($fileStatePersist)"
    rm -f "$fileStatePersist"
  fi
else
  logLine 0 "Error: invalid log mode ($logMode)"
  exitStatus=254
fi

rm -f "$fileRegex"
exit $exitStatus
