diff --git a/ddos-mitigator.sh b/ddos-mitigator.sh index 5d5153f..8270584 100755 --- a/ddos-mitigator.sh +++ b/ddos-mitigator.sh @@ -3,7 +3,7 @@ # # # Try and prevent apache overloads by banning IP addresses that have (too) # # 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 # # 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 # @@ -26,8 +26,8 @@ # - net-analyzer/fail2ban (`fail2ban-client`) # # - sys-apps/coreutils (`cut`, `id`, `sort`, `touch`, `tr`, `uniq`) # # - sys-apps/grep (`grep`) # +# - sys-apps/iproute2 (`ss`) # # - sys-apps/moreutils (`sponge`) # -# - sys-apps/net-tools (`netstat`) # # - sys-apps/util-linux (`getopt`) # # # ################################################################################ @@ -36,7 +36,7 @@ MY_IP="94.199.214.20" # After this point, no editing is required. -start=$(date +%s) +start="$(date +%s)" # Dependencies of this script. Simple array with the following structure: # (command package [...]) @@ -51,8 +51,8 @@ dependencies=( "tr" "sys-apps/coreutils" "uniq" "sys-apps/coreutils" "grep" "sys-apps/grep" + "ss" "sys-apps/iproute2" "sponge" "sys-apps/moreutils" - "netstat" "sys-apps/net-tools" "getopt" "sys-apps/util-linux" ) @@ -83,8 +83,8 @@ function is_installed() { } function print_missing_dependency() { - local command="$1" - local package="$2" + local command="${1}" + local package="${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 local all_installed=0 - for (( i=0; i<${arraylength}; i+=2 )) ; do + for ((i = 0; i < ${arraylength}; i += 2)); do command="${dependencies[$i]}" - package="${dependencies[$i+1]}" + package="${dependencies[$i + 1]}" is_installed "${command}" "${package}" res=$? - if [[ $res -ne 0 ]] ; then + if [[ ${res} -ne 0 ]]; then print_missing_dependency "${command}" "${package}" all_installed=1 fi @@ -165,7 +165,7 @@ ENDOFHELP } function exec_as_root() { - if [[ $(id -un) == "root" ]] ; then + if [[ $(id -un) == "root" ]]; then "$@" else sudo "$@" @@ -174,22 +174,22 @@ function exec_as_root() { function filter() { # list of current connections - file="$1" + file="${1}" # subnet extension, e.g. ".0.0" - ext="$2" + ext="${2}" # subnet suffix, e.g. "/16" - suffix="$3" + suffix="${3}" rm -f "${filtered}" touch "${filtered}" # Reject already banned addresses - while read -r -u3 address ; do - if [[ "${banned}" != *"${address}${ext}${suffix}"* ]] ; then - echo "${address}" >> "${filtered}" + while read -r -u3 address; do + if [[ "${banned}" != *"${address}${ext}${suffix}"* ]]; then + echo "${address}" >>"${filtered}" else echo "IGNORING ${address}${ext}${suffix}, already banned." fi - done 3< "${file}" + done 3<"${file}" mv "${filtered}" "${file}" } @@ -197,7 +197,7 @@ function filter() { 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' -- "$@") - if [ $? -ne 0 ] ; then + if [ $? -ne 0 ]; then echo 'Error parsing command line options. Terminating. Invoke with --help for help.' >&2 exit 1 fi @@ -205,91 +205,91 @@ function parse_command_line_args() { 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') - IFS=',' read -ra bancountries <<< "$2" - if [[ -z ${bancountries[@]// } ]] ; then - echo "Invalid argument for parameter 'country': '$2'. Invoke with --help for help." >&2 - exit 1 - fi - shift - ;; - '-d'|'--database') - database="$2" - shift - ;; - '-e'|'--dependencies') - check_dependencies - exit $? - ;; - '-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 - ;; - '-p'|'--port') - port="$2" - shift - ;; - '-h'|'--help') - print_help - exit - ;; - '--') - shift - break - ;; - *) - echo "Unknown error on command line argument '$1'. Terminating." >&2 + 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') + IFS=',' read -ra bancountries <<<"${2}" + if [[ -z ${bancountries[@]// /} ]]; then + echo "Invalid argument for parameter 'country': '${2}'. Invoke with --help for help." >&2 + exit 1 + fi + shift + ;; + '-d' | '--database') + database="${2}" + shift + ;; + '-e' | '--dependencies') + check_dependencies + exit $? + ;; + '-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 + ;; + '-p' | '--port') + port="${2}" + 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 + 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 + if [[ ! -r "${database}" ]]; then + echo "Database '${database}' is not accessible." >&2 exit 1 fi } @@ -301,52 +301,52 @@ function parse_command_line_args() { # color to the next happen at different values. ################################################################################ function set_highlight_color() { - local count=$1 + local count=${1} case "${choice}" in - "1" ) + "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 + if [ ${count} -ge 5 ]; then + hilite="${red}" + elif [ ${count} -ge 3 ]; then + hilite="${yellow}" + else + hilite="${green}" + fi ;; - "2" ) + "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 + if [ ${count} -ge 13 ]; then + hilite="${red}" + elif [ ${count} -ge 7 ]; then + hilite="${yellow}" + else + hilite="${green}" + fi ;; - "3" ) + "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 + if [ ${count} -ge 25 ]; then + hilite="${red}" + elif [ ${count} -ge 13 ]; then + hilite="${yellow}" + else + hilite="${green}" + fi ;; - "4" ) + "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 + 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="" + hilite="" ;; esac } @@ -357,7 +357,7 @@ function set_highlight_color() { # 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 () { +function process_file() { local file="${1}" local line='' local count=0 @@ -369,25 +369,25 @@ function process_file () { # 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 + 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 + if [[ ${autopilot} -eq 0 ]]; then echo "Country: '${yellow}${country}${reset}'" fi echo -n "Address ${bold}$((nline++)) of ${nlines}${reset}: \ 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]? " read banaction else - if [[ " ${bancountries[@]} " =~ " ${country} " ]] ; then - if [[ $count -ge $autopilot ]] ; then + if [[ " ${bancountries[@]} " =~ " ${country} " ]]; then + if [[ ${count} -ge ${autopilot} ]]; then echo -en "\n${red}Autopilot active. ${reset}" banaction=y else @@ -395,7 +395,7 @@ Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times." return fi else - if [[ $count -ge $autopilot ]] ; then + if [[ ${count} -ge ${autopilot} ]]; then echo -en "\n${green}Autopilot active. ${reset}" banaction=n else @@ -406,28 +406,28 @@ Found '${blue}${addrwithsuffix}${reset}' ${hilite}${count}${reset} times." fi case "${banaction}" in - "s" | "S" ) - echo "Not banning '${blue}${addrwithsuffix}${reset}', \ + "s" | "S") + echo "Not banning '${blue}${addrwithsuffix}${reset}', \ skipping remaining addresses." - return + return ;; - "y" | "Y" ) - echo "Adding '${blue}${addrwithsuffix}${reset}' to \ + "y" | "Y") + echo "Adding '${blue}${addrwithsuffix}${reset}' to \ banlist (country=${yellow}${country}${reset})." - echo "${addrwithsuffix}" >> "${banlist}" + echo "${addrwithsuffix}" >>"${banlist}" ;; - "n" | "N" | * ) - echo "Not banning '${blue}${addrwithsuffix}${reset}' (country=${yellow}${country}${reset})." + "n" | "N" | *) + echo "Not banning '${blue}${addrwithsuffix}${reset}' (country=${yellow}${country}${reset})." ;; esac - # Here goes: Pipe the file contents via filedescriptor 3. - done 3< "${file}" + # 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) +tmpdir="$(mktemp -d)" # Set up all file paths curdir="$(dirname "$0")" @@ -440,8 +440,6 @@ 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}" @@ -457,7 +455,7 @@ parse_command_line_args "$@" check_dependencies dependencies_ok=$? -if [[ ${dependencies_ok} -ne 0 ]] ; then +if [[ ${dependencies_ok} -ne 0 ]]; then exit ${dependencies_ok} 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 # $fileraw. -netstat -nt | grep "${MY_IP}:${port}" | tr -s '[:blank:]' | cut -d' ' -f5 \ - | cut -d: -f1 | sort > "${fileraw}" +connections=$(ss -HOn state established "( sport = :${port} )" | tr -s '[:blank:]' | cut -d' ' -f5) + +# 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. -cp "${fileraw}" "${file32}" -cut -d. -f1-3 "${fileraw}" | sort > "${file24}" -cut -d. -f1-2 "${fileraw}" | sort > "${file16}" -cut -d. -f1 "${fileraw}" | sort > "${file8}" +sort "${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}" @@ -493,7 +495,7 @@ nlines24=$(cat "${file24}" | wc -l) nlines16=$(cat "${file16}" | 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. echo "We've got:" echo "[1] 8bit: ${nlines8} entries" @@ -506,25 +508,25 @@ if [ ${netmask} -eq 0 ] ; then # $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 + "1") + netmask=8 ;; - "2" ) - netmask=16 + "2") + netmask=16 ;; - "3" ) - netmask=24 + "3") + netmask=24 ;; - "4" ) - netmask=32 + "4") + netmask=32 ;; - "Q" | "q" ) - echo "You chose to abort. That's fine! Have a nice day!" - exit + "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 + *) + echo "Invalid input: ${choice}. I'm out of here." + exit 1 ;; esac fi @@ -554,11 +556,11 @@ sudo -k # Iterate over all addresses in $banlist and invoke fail2ban-client on each # one of them. -while read -r addrwithsuffix ; do +while read -r addrwithsuffix; do echo "Banning ${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}"