#!/bin/sh ################################################################################ ################################################################################ ########### FIXME: This text is outdated and needs to be rewritten. ########### ################################################################################ ################################################################################ ################################################################################ # # # Try and prevent apache overloads by banning IP addresses that have (too) # # many open connections. # # This script uses netstat to determine the connections to the HTTPS port of # # the host machine and provides automated whois information retrieval based on # # the address or the /24-, /16- or /8-subnet thereof. Addresses (or subnets) # # are presented to the user in order of descending connection count. For each # # address (or subnet), the user can choose to ban or ignore it. Addresses (or # # subnets) chosen to be banned will be blocked by the apache-badbots jail of # # fail2ban. # # Author: Manuel Friedli, # # This script is licenced under the GNU General Public Licence, version 3 or # # later. # # # ################################################################################ ################################################################################ # # # Prerequisites: # # - app-admin/sudo (`sudo`) # # - dev-lang/python:3.8 (`python`) # # - net-analyzer/fail2ban (`fail2ban-client`) # # - sys-apps/coreutils (`cut`, `id`, `sort`, `touch`, `tr`, `uniq`) # # - sys-apps/grep (`grep`) # # - sys-apps/moreutils (`sponge`) # # - sys-apps/net_tools (`netstat`) # # - sys-apps/util-linux (`getopt`) # # # ################################################################################ # Set the host's own IP address. So far, only an IPv4 address is supported. MY_IP="94.199.214.20" # Set the desired port to monitor. MY_PORT="443" # After this point, no editing is required. start=$(date +%s) # These suffixes must be appended to the respective addresses and subnets. suffix8="/8" suffix16="/16" suffix24="/24" suffix32="/32" ext8=".0.0.0" ext16=".0.0" ext24=".0" ext32="" # Define some constants to format the output in a colorful way. red="$(printf '\033[38;2;255;0;43m')" yellow="$(printf '\033[38;2;255;204;0m')" green="$(printf '\033[38;2;0;179;89m')" blue="$(printf '\033[38;2;0;85;255m')" bold="$(printf '\033[1m')" reset="$(printf '\033[0m')" # Clean up when the script exits. trap 'sudo -k; rm -r ${tmpdir}' EXIT function check_installed() { local command="$1" local package="$2" which "${command}" 2>/dev/null >&2 local result=$? if [[ "${result}" -ne 0 ]] ; then echo "${red}Command ${bold}${command}${reset}${red} not found.${reset} Please install package ${blue}${package}${reset}." exit 1 fi } function print_help() { cat <> "${filtered}" else echo "IGNORING ${address}${ext}${suffix}, already banned." fi done 3< "${file}" mv "${filtered}" "${file}" } function parse_command_line_args() { TEMP=$(getopt -o 'a::,c:,d:,j:,n:,h' -l 'auto::,country:,database:,jail:,netmask:,help' -- "$@") if [ $? -ne 0 ] ; then echo 'Error parsing command line options. Terminating. Invoke with --help for help.' >&2 exit 1 fi eval set -- "${TEMP}" unset TEMP while true ; do case "$1" in '-a'|'--auto') case "$2" in '') autopilot=1 ;; *[!0-9]*) echo "Invalid argument for parameter 'auto': '$2'. Invoke with --help for help." >&2 exit 1 ;; *) autopilot="$2" ;; esac shift ;; '-c'|'--country') bancountry="$2" shift ;; '-d'|'--database') database="$2" shift ;; '-j'|'--jail') jail="$2" shift ;; '-n'|'--netmask') case "$2" in '1'|'8') netmask=8 ;; '2'|'16') netmask=16 ;; '3'|'24') netmask=24 ;; '4'|'32') netmask=32 ;; *) echo "Invalid argument for parameter 'netmask': '$2'. Invoke with --help for help." >&2 exit 1 ;; esac shift ;; '-h'|'--help') print_help exit ;; '--') shift break ;; *) echo "Unknown error on command line argument '$1'. Terminating." >&2 exit 1 ;; esac shift done if [[ -z "${database}" ]] ; then echo "No GeoIP database specified. Invoke with --help for more information." >&2 exit 1 fi if [[ ! -r "${database}" ]] ; then echo "Database '${database}' is not accessible." >&2 exit 1 fi } ################################################################################ # Set the highlighting color for the address count. The color goes from green # (low count) over yellow (intermediate count) to red (high count). Depending # on the size of the subnet (/8, /16, /24 or /32), the transitions from one # color to the next happen at different values. ################################################################################ function set_highlight_color() { local count=$1 case "${choice}" in "1" ) # /32: 0 <= green < 3 <= yellow < 5 <= red if [ $count -ge 5 ] ; then hilite="${red}" elif [ $count -ge 3 ] ; then hilite="${yellow}" else hilite="${green}" fi ;; "2" ) # /24: 0 <= green < 7 <= yellow < 13 <= red if [ $count -ge 13 ] ; then hilite="${red}" elif [ $count -ge 7 ] ; then hilite="${yellow}" else hilite="${green}" fi ;; "3" ) # /16: 0 <= green < 13 <= yellow < 25 <= red if [ $count -ge 25 ] ; then hilite="${red}" elif [ $count -ge 13 ] ; then hilite="${yellow}" else hilite="${green}" fi ;; "4" ) # /8: 0 <= green < 21 <= yellow < 49 <= red if [ $count -ge 49 ] ; then hilite="${red}" elif [ $count -ge 21 ] ; then hilite="${yellow}" else hilite="${green}" fi ;; * ) # ???: We should never get here. As a fall-back, just use no # highlighting. hilite="" ;; esac } ################################################################################ # Process the file denoted by $1. For each line in the file, the count and the # address are displayed and a lookup for the IP addresses country is made in the # GeoIP database. The user can then choose to ban or ignore the address. # Addresses chosen to be banned are appended to the $banlist. ################################################################################ function process_file () { local file="${1}" local line='' local count=0 local addronly='' local addrwithsuffix='' local banaction='' local nline=1 local country= # Read the contents from filedescriptor 3 (important: Don's use the # standard filedescriptor because we need to handle user input from # within the loop). while IFS= read -r -u3 line ; do line="$(echo "${line}" | tr -s '[:blank:]')" count="$(echo "${line}" | cut -d' ' -f2)" addronly="$(echo "${line}" | cut -d' ' -f3-)${ext}" addrwithsuffix="${addronly}${suffix}" set_highlight_color "${count}" country="$("${curdir}/geoip-lookup.py" -f "${database}" "${addronly}")" if [[ autopilot -eq 0 ]] ; then echo "Country: '${country}'" fi echo -n "Address ${bold}$((nline++)) of ${nlines}${reset}: \ Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times." if [[ ${autopilot} -eq 0 ]] ; then echo -n "Ban [y/N/s=No, and skip remaining]? " read banaction else if [[ "${country}" == "${bancountry}" ]] ; then if [[ $count -ge $autopilot ]] ; then echo -en "\n${red}Autopilot active. ${reset}" banaction=y else echo -e "\n${yellow}Autopilot active. Ignoring remaining addresses due to limit of ${autopilot}.${reset}" return fi else if [[ $count -ge $autopilot ]] ; then echo -en "\n${green}Autopilot active. ${reset}" banaction=n else echo -e "\n${green}Autopilot active.${reset} ${yellow}Ignoring remaining addresses due to limit of ${autopilot}.${reset}" return fi fi fi case "${banaction}" in "s" | "S" ) echo "Not banning '${blue}${addrwithsuffix}${reset}', \ skipping remaining addresses." return ;; "y" | "Y" ) echo "Adding '${blue}${addrwithsuffix}${reset}' to \ banlist." echo "${addrwithsuffix}" >> "${banlist}" ;; "n" | "N" | * ) echo "Not banning '${blue}${addrwithsuffix}${reset}' (country='${country}')." ;; esac # Here goes: Pipe the file contents via filedescriptor 3. done 3< "${file}" echo "Processed all entries in ${file}." } # Create a temp directory, chdir into it and create the (initially empty) # banlist file. tmpdir=$(mktemp -d) # Set up all file paths curdir="$(dirname "$0")" # Define the files that will contain the addresses an subnets. fileraw="${tmpdir}/raw-http.txt" filtered="${tmpdir}/filtered-http.txt" file8="${tmpdir}/sorted-http-8.txt" file16="${tmpdir}/sorted-http-16.txt" file24="${tmpdir}/sorted-http-24.txt" file32="${tmpdir}/sorted-http-32.txt" # This file will contain the addresses to be banned. banlist="${tmpdir}/banlist.txt" # This file contains the output of the last invocation of whois whoisoutput="${tmpdir}/whois.txt" touch "${banlist}" # Parse the command line options autopilot=0 netmask=0 jail="apache-auth" bancountry="CN" database= parse_command_line_args "$@" check_installed "sudo" "app-admin/sudo" check_installed "python" "dev-lang/python:3.8" check_installed "fail2ban-client" "net-analyzer/fail2ban" check_installed "cut" "sys-apps/coreutils" check_installed "id" "sys-apps/coreutils" check_installed "sort" "sys-apps/coreutils" check_installed "touch" "sys-apps/coreutils" check_installed "tr" "sys-apps/coreutils" check_installed "uniq" "sys-apps/coreutils" check_installed "grep" "sys-apps/grep" check_installed "sponge" "sys-apps/moreutils" check_installed "netstat" "sys-apps/net_tools" check_installed "getopt" "sys-apps/util-linux" # List already banned addresses in the chosen jail banned="$(exec_as_root fail2ban-client get "${jail}" banip)" # Determine the current connections to the desired port; store the raw data in # $fileraw. netstat -nt | grep "${MY_IP}:${MY_PORT}" | tr -s '[:blank:]' | cut -d' ' -f5 \ | cut -d: -f1 | sort > "${fileraw}" # Group and sort the data into the subnet-specific files. cp "${fileraw}" "${file32}" cut -d. -f1-3 "${fileraw}" | sort > "${file24}" cut -d. -f1-2 "${fileraw}" | sort > "${file16}" cut -d. -f1 "${fileraw}" | sort > "${file8}" # Filter already banned addresses filter "${file32}" "${ext32}" "${suffix32}" filter "${file24}" "${ext24}" "${suffix24}" filter "${file16}" "${ext16}" "${suffix16}" filter "${file8}" "${ext8}" "${suffix8}" # Determine the number of connections per address uniq -c "${file32}" | sort -rn | sponge "${file32}" uniq -c "${file24}" | sort -rn | sponge "${file24}" uniq -c "${file16}" | sort -rn | sponge "${file16}" uniq -c "${file8}" | sort -rn | sponge "${file8}" # Determine the number of entries per file. nlines32=$(cat "${file32}" | wc -l) nlines24=$(cat "${file24}" | wc -l) nlines16=$(cat "${file16}" | wc -l) nlines8=$(cat "${file8}" | wc -l) if [ ${netmask} -eq 0 ] ; then # Now let the user choose which file to process. echo "We've got:" echo "[1] 8bit: ${nlines8} entries" echo "[2] 16bit: ${nlines16} entries" echo "[3] 24bit: ${nlines24} entries" echo "[4] 32bit: ${nlines32} entries" read -p 'Which one do you want to work with (q=Quit) [1-4]? ' choice # Based on the user's choice, initialize the variables $file, $ext and # $nlines, which will be used after this point. Also, $choice will be # used to color the output based on subnet-type. case "${choice}" in "1" ) netmask=8 ;; "2" ) netmask=16 ;; "3" ) netmask=24 ;; "4" ) netmask=32 ;; "Q" | "q" ) echo "You chose to abort. That's fine! Have a nice day!" exit ;; * ) echo "Invalid input: ${choice}. I'm out of here." exit 1 ;; esac fi # Now initialize the variables $file, $ext and $nlines based on the chosen $netmask TEMP="file${netmask}" file="${!TEMP}" TEMP="ext${netmask}" ext="${!TEMP}" TEMP="suffix${netmask}" suffix="${!TEMP}" TEMP="nlines${netmask}" nlines="${!TEMP}" unset TEMP echo "Processing ${file}." # Invoke the processing function on the chosen file. process_file "${file}" echo "These are the addresses to be banned:" cat "${banlist}" # Make sure the user has to (re)-identify him- or herself before actually # banning anything. sudo -k # Iterate over all addresses in $banlist and invoke fail2ban-client on each # one of them. while read -r addrwithsuffix ; do echo "Banning ${addrwithsuffix} ..." exec_as_root fail2ban-client set "${jail}" banip "${addrwithsuffix}" done < "${banlist}" end=$(date +%s) echo "${green}All done in $((end - start)) seconds!${reset}"