small-package/luci-app-beardropper/root/usr/sbin/beardropper

518 lines
19 KiB
Bash
Executable File

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