Merge pull request 'feature/ss-instead-of-netstat' (#2) from feature/ss-instead-of-netstat into master

Reviewed-on: #2
This commit is contained in:
Manuel Friedli 2020-09-18 14:24:41 +02:00
commit b65bd6ea55

View file

@ -3,7 +3,7 @@
# # # #
# Try and prevent apache overloads by banning IP addresses that have (too) # # Try and prevent apache overloads by banning IP addresses that have (too) #
# many open connections. # # many open connections. #
# This script uses netstat to determine the connections to a configurable port # # This script uses ss to determine the connections to a configurable port #
# on the host machine and provides automated GeoIP information retrieval based # # on the host machine and provides automated GeoIP information retrieval based #
# the address or the /24-, /16- or /8-subnet thereof. A GeoIP city- or country # # the address or the /24-, /16- or /8-subnet thereof. A GeoIP city- or country #
# database must be installed separately and is provided to the script via a # # database must be installed separately and is provided to the script via a #
@ -26,8 +26,8 @@
# - net-analyzer/fail2ban (`fail2ban-client`) # # - net-analyzer/fail2ban (`fail2ban-client`) #
# - sys-apps/coreutils (`cut`, `id`, `sort`, `touch`, `tr`, `uniq`) # # - sys-apps/coreutils (`cut`, `id`, `sort`, `touch`, `tr`, `uniq`) #
# - sys-apps/grep (`grep`) # # - sys-apps/grep (`grep`) #
# - sys-apps/iproute2 (`ss`) #
# - sys-apps/moreutils (`sponge`) # # - sys-apps/moreutils (`sponge`) #
# - sys-apps/net-tools (`netstat`) #
# - sys-apps/util-linux (`getopt`) # # - sys-apps/util-linux (`getopt`) #
# # # #
################################################################################ ################################################################################
@ -36,7 +36,7 @@
MY_IP="94.199.214.20" MY_IP="94.199.214.20"
# After this point, no editing is required. # After this point, no editing is required.
start=$(date +%s) start="$(date +%s)"
# Dependencies of this script. Simple array with the following structure: # Dependencies of this script. Simple array with the following structure:
# (command package [...]) # (command package [...])
@ -51,8 +51,8 @@ dependencies=(
"tr" "sys-apps/coreutils" "tr" "sys-apps/coreutils"
"uniq" "sys-apps/coreutils" "uniq" "sys-apps/coreutils"
"grep" "sys-apps/grep" "grep" "sys-apps/grep"
"ss" "sys-apps/iproute2"
"sponge" "sys-apps/moreutils" "sponge" "sys-apps/moreutils"
"netstat" "sys-apps/net-tools"
"getopt" "sys-apps/util-linux" "getopt" "sys-apps/util-linux"
) )
@ -83,8 +83,8 @@ function is_installed() {
} }
function print_missing_dependency() { function print_missing_dependency() {
local command="$1" local command="${1}"
local package="$2" local package="${2}"
echo "${red}Command ${bold}${command}${reset}${red} not found.${reset} Please install package ${blue}${package}${reset}." >&2 echo "${red}Command ${bold}${command}${reset}${red} not found.${reset} Please install package ${blue}${package}${reset}." >&2
} }
@ -97,12 +97,12 @@ function check_dependencies() {
# 0: true, all installed; 1: false, at least one command/package missing # 0: true, all installed; 1: false, at least one command/package missing
local all_installed=0 local all_installed=0
for (( i=0; i<${arraylength}; i+=2 )) ; do for ((i = 0; i < ${arraylength}; i += 2)); do
command="${dependencies[$i]}" command="${dependencies[$i]}"
package="${dependencies[$i+1]}" package="${dependencies[$i + 1]}"
is_installed "${command}" "${package}" is_installed "${command}" "${package}"
res=$? res=$?
if [[ $res -ne 0 ]] ; then if [[ ${res} -ne 0 ]]; then
print_missing_dependency "${command}" "${package}" print_missing_dependency "${command}" "${package}"
all_installed=1 all_installed=1
fi fi
@ -165,7 +165,7 @@ ENDOFHELP
} }
function exec_as_root() { function exec_as_root() {
if [[ $(id -un) == "root" ]] ; then if [[ $(id -un) == "root" ]]; then
"$@" "$@"
else else
sudo "$@" sudo "$@"
@ -174,22 +174,22 @@ function exec_as_root() {
function filter() { function filter() {
# list of current connections # list of current connections
file="$1" file="${1}"
# subnet extension, e.g. ".0.0" # subnet extension, e.g. ".0.0"
ext="$2" ext="${2}"
# subnet suffix, e.g. "/16" # subnet suffix, e.g. "/16"
suffix="$3" suffix="${3}"
rm -f "${filtered}" rm -f "${filtered}"
touch "${filtered}" touch "${filtered}"
# Reject already banned addresses # Reject already banned addresses
while read -r -u3 address ; do while read -r -u3 address; do
if [[ "${banned}" != *"${address}${ext}${suffix}"* ]] ; then if [[ "${banned}" != *"${address}${ext}${suffix}"* ]]; then
echo "${address}" >> "${filtered}" echo "${address}" >>"${filtered}"
else else
echo "IGNORING ${address}${ext}${suffix}, already banned." echo "IGNORING ${address}${ext}${suffix}, already banned."
fi fi
done 3< "${file}" done 3<"${file}"
mv "${filtered}" "${file}" mv "${filtered}" "${file}"
} }
@ -197,7 +197,7 @@ function filter() {
function parse_command_line_args() { function parse_command_line_args() {
TEMP=$(getopt -o 'a::,c:,d:,e,j:,n:,p:,h' -l 'auto::,country:,database:,dependencies,jail:,netmask:,port:,help' -- "$@") TEMP=$(getopt -o 'a::,c:,d:,e,j:,n:,p:,h' -l 'auto::,country:,database:,dependencies,jail:,netmask:,port:,help' -- "$@")
if [ $? -ne 0 ] ; then if [ $? -ne 0 ]; then
echo 'Error parsing command line options. Terminating. Invoke with --help for help.' >&2 echo 'Error parsing command line options. Terminating. Invoke with --help for help.' >&2
exit 1 exit 1
fi fi
@ -205,15 +205,15 @@ function parse_command_line_args() {
eval set -- "${TEMP}" eval set -- "${TEMP}"
unset TEMP unset TEMP
while true ; do while true; do
case "$1" in case "${1}" in
'-a'|'--auto') '-a' | '--auto')
case "$2" in case "${2}" in
'') '')
autopilot=1 autopilot=1
;; ;;
*[!0-9]*) *[!0-9]*)
echo "Invalid argument for parameter 'auto': '$2'. Invoke with --help for help." >&2 echo "Invalid argument for parameter 'auto': '${2}'. Invoke with --help for help." >&2
exit 1 exit 1
;; ;;
*) *)
@ -222,52 +222,52 @@ function parse_command_line_args() {
esac esac
shift shift
;; ;;
'-c'|'--country') '-c' | '--country')
IFS=',' read -ra bancountries <<< "$2" IFS=',' read -ra bancountries <<<"${2}"
if [[ -z ${bancountries[@]// } ]] ; then if [[ -z ${bancountries[@]// /} ]]; then
echo "Invalid argument for parameter 'country': '$2'. Invoke with --help for help." >&2 echo "Invalid argument for parameter 'country': '${2}'. Invoke with --help for help." >&2
exit 1 exit 1
fi fi
shift shift
;; ;;
'-d'|'--database') '-d' | '--database')
database="$2" database="${2}"
shift shift
;; ;;
'-e'|'--dependencies') '-e' | '--dependencies')
check_dependencies check_dependencies
exit $? exit $?
;; ;;
'-j'|'--jail') '-j' | '--jail')
jail="$2" jail="${2}"
shift shift
;; ;;
'-n'|'--netmask') '-n' | '--netmask')
case "$2" in case "${2}" in
'1'|'8') '1' | '8')
netmask=8 netmask=8
;; ;;
'2'|'16') '2' | '16')
netmask=16 netmask=16
;; ;;
'3'|'24') '3' | '24')
netmask=24 netmask=24
;; ;;
'4'|'32') '4' | '32')
netmask=32 netmask=32
;; ;;
*) *)
echo "Invalid argument for parameter 'netmask': '$2'. Invoke with --help for help." >&2 echo "Invalid argument for parameter 'netmask': '${2}'. Invoke with --help for help." >&2
exit 1 exit 1
;; ;;
esac esac
shift shift
;; ;;
'-p'|'--port') '-p' | '--port')
port="$2" port="${2}"
shift shift
;; ;;
'-h'|'--help') '-h' | '--help')
print_help print_help
exit exit
;; ;;
@ -276,19 +276,19 @@ function parse_command_line_args() {
break break
;; ;;
*) *)
echo "Unknown error on command line argument '$1'. Terminating." >&2 echo "Unknown error on command line argument '${1}'. Terminating." >&2
exit 1 exit 1
;; ;;
esac esac
shift shift
done done
if [[ -z "${database}" ]] ; then if [[ -z "${database}" ]]; then
echo "No GeoIP database specified. Invoke with --help for more information." >&2 echo "No GeoIP database specified. Invoke with --help for more information." >&2
exit 1 exit 1
fi fi
if [[ ! -r "${database}" ]] ; then if [[ ! -r "${database}" ]]; then
echo "Database '${database}' is not accessible." >&2 echo "Database '${database}' is not accessible." >&2
exit 1 exit 1
fi fi
@ -301,49 +301,49 @@ function parse_command_line_args() {
# color to the next happen at different values. # color to the next happen at different values.
################################################################################ ################################################################################
function set_highlight_color() { function set_highlight_color() {
local count=$1 local count=${1}
case "${choice}" in case "${choice}" in
"1" ) "1")
# /32: 0 <= green < 3 <= yellow < 5 <= red # /32: 0 <= green < 3 <= yellow < 5 <= red
if [ $count -ge 5 ] ; then if [ ${count} -ge 5 ]; then
hilite="${red}" hilite="${red}"
elif [ $count -ge 3 ] ; then elif [ ${count} -ge 3 ]; then
hilite="${yellow}" hilite="${yellow}"
else else
hilite="${green}" hilite="${green}"
fi fi
;; ;;
"2" ) "2")
# /24: 0 <= green < 7 <= yellow < 13 <= red # /24: 0 <= green < 7 <= yellow < 13 <= red
if [ $count -ge 13 ] ; then if [ ${count} -ge 13 ]; then
hilite="${red}" hilite="${red}"
elif [ $count -ge 7 ] ; then elif [ ${count} -ge 7 ]; then
hilite="${yellow}" hilite="${yellow}"
else else
hilite="${green}" hilite="${green}"
fi fi
;; ;;
"3" ) "3")
# /16: 0 <= green < 13 <= yellow < 25 <= red # /16: 0 <= green < 13 <= yellow < 25 <= red
if [ $count -ge 25 ] ; then if [ ${count} -ge 25 ]; then
hilite="${red}" hilite="${red}"
elif [ $count -ge 13 ] ; then elif [ ${count} -ge 13 ]; then
hilite="${yellow}" hilite="${yellow}"
else else
hilite="${green}" hilite="${green}"
fi fi
;; ;;
"4" ) "4")
# /8: 0 <= green < 21 <= yellow < 49 <= red # /8: 0 <= green < 21 <= yellow < 49 <= red
if [ $count -ge 49 ] ; then if [ ${count} -ge 49 ]; then
hilite="${red}" hilite="${red}"
elif [ $count -ge 21 ] ; then elif [ ${count} -ge 21 ]; then
hilite="${yellow}" hilite="${yellow}"
else else
hilite="${green}" hilite="${green}"
fi fi
;; ;;
* ) *)
# ???: We should never get here. As a fall-back, just use no # ???: We should never get here. As a fall-back, just use no
# highlighting. # highlighting.
hilite="" hilite=""
@ -357,7 +357,7 @@ function set_highlight_color() {
# GeoIP database. The user can then choose to ban or ignore the address. # GeoIP database. The user can then choose to ban or ignore the address.
# Addresses chosen to be banned are appended to the $banlist. # Addresses chosen to be banned are appended to the $banlist.
################################################################################ ################################################################################
function process_file () { function process_file() {
local file="${1}" local file="${1}"
local line='' local line=''
local count=0 local count=0
@ -369,25 +369,25 @@ function process_file () {
# Read the contents from filedescriptor 3 (important: Don's use the # Read the contents from filedescriptor 3 (important: Don's use the
# standard filedescriptor because we need to handle user input from # standard filedescriptor because we need to handle user input from
# within the loop). # within the loop).
while IFS= read -r -u3 line ; do while IFS= read -r -u3 line; do
line="$(echo "${line}" | tr -s '[:blank:]')" line="$(echo "${line}" | tr -s '[:blank:]')"
count="$(echo "${line}" | cut -d' ' -f2)" count="$(echo "${line}" | cut -d' ' -f2)"
addronly="$(echo "${line}" | cut -d' ' -f3-)${ext}" addronly="$(echo "${line}" | cut -d' ' -f3-)${ext}"
addrwithsuffix="${addronly}${suffix}" addrwithsuffix="${addronly}${suffix}"
set_highlight_color "${count}" set_highlight_color "${count}"
country="$("${curdir}/geoip-lookup.py" -f "${database}" "${addronly}")" country="$("${curdir}/geoip-lookup.py" -f "${database}" "${addronly}")"
if [[ autopilot -eq 0 ]] ; then if [[ ${autopilot} -eq 0 ]]; then
echo "Country: '${yellow}${country}${reset}'" echo "Country: '${yellow}${country}${reset}'"
fi fi
echo -n "Address ${bold}$((nline++)) of ${nlines}${reset}: \ echo -n "Address ${bold}$((nline++)) of ${nlines}${reset}: \
Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times." Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times."
if [[ ${autopilot} -eq 0 ]] ; then if [[ ${autopilot} -eq 0 ]]; then
echo -n " Ban [y/N/s=No, and skip remaining]? " echo -n " Ban [y/N/s=No, and skip remaining]? "
read banaction read banaction
else else
if [[ " ${bancountries[@]} " =~ " ${country} " ]] ; then if [[ " ${bancountries[@]} " =~ " ${country} " ]]; then
if [[ $count -ge $autopilot ]] ; then if [[ ${count} -ge ${autopilot} ]]; then
echo -en "\n${red}Autopilot active. ${reset}" echo -en "\n${red}Autopilot active. ${reset}"
banaction=y banaction=y
else else
@ -395,7 +395,7 @@ Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times."
return return
fi fi
else else
if [[ $count -ge $autopilot ]] ; then if [[ ${count} -ge ${autopilot} ]]; then
echo -en "\n${green}Autopilot active. ${reset}" echo -en "\n${green}Autopilot active. ${reset}"
banaction=n banaction=n
else else
@ -406,28 +406,28 @@ Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times."
fi fi
case "${banaction}" in case "${banaction}" in
"s" | "S" ) "s" | "S")
echo "Not banning '${blue}${addrwithsuffix}${reset}', \ echo "Not banning '${blue}${addrwithsuffix}${reset}', \
skipping remaining addresses." skipping remaining addresses."
return return
;; ;;
"y" | "Y" ) "y" | "Y")
echo "Adding '${blue}${addrwithsuffix}${reset}' to \ echo "Adding '${blue}${addrwithsuffix}${reset}' to \
banlist (country=${yellow}${country}${reset})." banlist (country=${yellow}${country}${reset})."
echo "${addrwithsuffix}" >> "${banlist}" echo "${addrwithsuffix}" >>"${banlist}"
;; ;;
"n" | "N" | * ) "n" | "N" | *)
echo "Not banning '${blue}${addrwithsuffix}${reset}' (country=${yellow}${country}${reset})." echo "Not banning '${blue}${addrwithsuffix}${reset}' (country=${yellow}${country}${reset})."
;; ;;
esac esac
# Here goes: Pipe the file contents via filedescriptor 3. # Here goes: Pipe the file contents via filedescriptor 3.
done 3< "${file}" done 3<"${file}"
echo "Processed all entries in ${file}." echo "Processed all entries in ${file}."
} }
# Create a temp directory, chdir into it and create the (initially empty) # Create a temp directory, chdir into it and create the (initially empty)
# banlist file. # banlist file.
tmpdir=$(mktemp -d) tmpdir="$(mktemp -d)"
# Set up all file paths # Set up all file paths
curdir="$(dirname "$0")" curdir="$(dirname "$0")"
@ -440,8 +440,6 @@ file24="${tmpdir}/sorted-http-24.txt"
file32="${tmpdir}/sorted-http-32.txt" file32="${tmpdir}/sorted-http-32.txt"
# This file will contain the addresses to be banned. # This file will contain the addresses to be banned.
banlist="${tmpdir}/banlist.txt" banlist="${tmpdir}/banlist.txt"
# This file contains the output of the last invocation of whois
whoisoutput="${tmpdir}/whois.txt"
touch "${banlist}" touch "${banlist}"
@ -457,7 +455,7 @@ parse_command_line_args "$@"
check_dependencies check_dependencies
dependencies_ok=$? dependencies_ok=$?
if [[ ${dependencies_ok} -ne 0 ]] ; then if [[ ${dependencies_ok} -ne 0 ]]; then
exit ${dependencies_ok} exit ${dependencies_ok}
fi fi
@ -466,14 +464,18 @@ banned="$(exec_as_root fail2ban-client get "${jail}" banip)"
# Determine the current connections to the desired port; store the raw data in # Determine the current connections to the desired port; store the raw data in
# $fileraw. # $fileraw.
netstat -nt | grep "${MY_IP}:${port}" | tr -s '[:blank:]' | cut -d' ' -f5 \ connections=$(ss -HOn state established "( sport = :${port} )" | tr -s '[:blank:]' | cut -d' ' -f5)
| cut -d: -f1 | sort > "${fileraw}"
# IPv6-mapped-IPv4: [::ffff:192.168.0.1]:443
echo "${connections}" | grep '^\[::ffff:' - | cut -d: -f4 | cut -d] -f1 | grep -v '^$' >"${fileraw}"
# Pure IPv4: 192.168.0.1:443
echo "${connections}" | grep -v '^\[' - | cut -d: -f1 | grep -v '^$' >>"${fileraw}"
# Group and sort the data into the subnet-specific files. # Group and sort the data into the subnet-specific files.
cp "${fileraw}" "${file32}" sort "${fileraw}" >"${file32}"
cut -d. -f1-3 "${fileraw}" | sort > "${file24}" cut -d. -f1-3 "${fileraw}" | sort >"${file24}"
cut -d. -f1-2 "${fileraw}" | sort > "${file16}" cut -d. -f1-2 "${fileraw}" | sort >"${file16}"
cut -d. -f1 "${fileraw}" | sort > "${file8}" cut -d. -f1 "${fileraw}" | sort >"${file8}"
# Filter already banned addresses # Filter already banned addresses
filter "${file32}" "${ext32}" "${suffix32}" filter "${file32}" "${ext32}" "${suffix32}"
@ -493,7 +495,7 @@ nlines24=$(cat "${file24}" | wc -l)
nlines16=$(cat "${file16}" | wc -l) nlines16=$(cat "${file16}" | wc -l)
nlines8=$(cat "${file8}" | wc -l) nlines8=$(cat "${file8}" | wc -l)
if [ ${netmask} -eq 0 ] ; then if [ ${netmask} -eq 0 ]; then
# Now let the user choose which file to process. # Now let the user choose which file to process.
echo "We've got:" echo "We've got:"
echo "[1] 8bit: ${nlines8} entries" echo "[1] 8bit: ${nlines8} entries"
@ -506,23 +508,23 @@ if [ ${netmask} -eq 0 ] ; then
# $nlines, which will be used after this point. Also, $choice will be # $nlines, which will be used after this point. Also, $choice will be
# used to color the output based on subnet-type. # used to color the output based on subnet-type.
case "${choice}" in case "${choice}" in
"1" ) "1")
netmask=8 netmask=8
;; ;;
"2" ) "2")
netmask=16 netmask=16
;; ;;
"3" ) "3")
netmask=24 netmask=24
;; ;;
"4" ) "4")
netmask=32 netmask=32
;; ;;
"Q" | "q" ) "Q" | "q")
echo "You chose to abort. That's fine! Have a nice day!" echo "You chose to abort. That's fine! Have a nice day!"
exit exit
;; ;;
* ) *)
echo "Invalid input: ${choice}. I'm out of here." echo "Invalid input: ${choice}. I'm out of here."
exit 1 exit 1
;; ;;
@ -554,11 +556,11 @@ sudo -k
# Iterate over all addresses in $banlist and invoke fail2ban-client on each # Iterate over all addresses in $banlist and invoke fail2ban-client on each
# one of them. # one of them.
while read -r addrwithsuffix ; do while read -r addrwithsuffix; do
echo "Banning ${addrwithsuffix} ..." echo "Banning ${addrwithsuffix} ..."
exec_as_root fail2ban-client set "${jail}" banip "${addrwithsuffix}" exec_as_root fail2ban-client set "${jail}" banip "${addrwithsuffix}"
done < "${banlist}" done <"${banlist}"
end=$(date +%s) end="$(date +%s)"
echo "${green}All done in $((end - start)) seconds!${reset}" echo "${green}All done in $((end - start)) seconds!${reset}"