#!/bin/ksh
# banner - ban problem hosts from accessing this node.
# Author: Perette Barella
# $Id: banner 117 2025-12-27 02:18:04Z perette $
#
# This script uses journalctl to find failed ssh login attempts with in
# some recent duration, and collates by originating IP address.  Addresses
# with to many failed login attempts during this period (and aren't already
# in /dev/hosts.deny) are output to stdout in a format suitable for appending
# to /etc/hosts.deny.  To set this script up, add it to root's crontab to
# run periodically, using shell redirection (>>) to append to /etc/hosts.deny.
#
# The script ignores IPs from the assigned private blocks, since these
# generally represent safe hosts.  However, if you fatfinger your password
# enough times in a short duration from the proper net, it will happily
# ban that origin.  Choose thresholds appropriately.
#

# Return a date-time according to the phrase described in $*.
function get_past_time {
	date -d "$*" '+%Y-%m-%d %H:%M:%S' 
}


# Check if an IP address is in one of the well-known internal/private
# IP blocks.
function is_internal_ip {
	typeset address="$1"
	[[ $address == 10.* ]] && return 0
	[[ $address == 192.168.* ]] && return 0
	[[ $address == f[cd][[:xdigit:]][[:xdigit:]]:* ]] && return 0
	if [[ $address == 172.[123][0-9].* ]]
	then
		typeset second=${address:4:2}
		(( second >= 16 && second <= 31)) && return 0
	fi
	return 1
}


# Check if $1 is an IP address.
# This is not perfect but will at least catch the obvious/likely.
function is_ip_address {
	[[ $1 == {1,3}([0-9]).{1,3}([0-9]).{1,3}([0-9]).{1,3}([0-9]) ]] && return 0
	[[ $1 == {1,39}([0-9a-fA-F:]) ]] && return 0
	return 1
}


if [[ "$1" == "RUN_UNIT_TEST_NOW" ]]
then
	get_past_time "now - 2 days" >/dev/null || print "get_past_time failed."
	get_past_time "nonsense" >/dev/null 2>&1 && print "get_past_time worked with nonsense."

	is_ip_address 192.168.1.2 || print "Error with 192.168.1.2"
	is_ip_address 35.2.9 && print "Short IPv4 accepted."
	is_ip_address 35.2.9.9999 && print "Faulty IPv4 accepted."
	is_ip_address 35.2.9.99.39 && print "Long IPv4 accepted."
	is_ip_address 35.2.o.39 && print "Invalid IPv4 accepted."
	is_ip_address 2001:db8:ac10:fe01::3905 || print "Error IPv6 short"
	is_ip_address 2001:5db8:ac10:fe01:0000:0000:0000:0000 || print "Error with IPv6 full"
	is_ip_address 2001:5db8:fg01::0000 && print "Invalid IPv6 accepted."
	is_ip_address fred && print "Invalid IP address accepted."

	is_internal_ip 54.139.22.175 && print "54.139.22.175 is internal."
	is_internal_ip 192.168.100.1 || print "192.168.100.1 is not private."
	is_internal_ip fc23:37::8303 || print "fc23::... is not private."
	is_internal_ip 172.15.32.45 && print "172.15.32.46 is internal."
	is_internal_ip 172.16.3.127 || print "172.16.3.127 is not private."
	is_internal_ip 172.31.254.7 || print "172.31.254.7 is not private."
	is_internal_ip 172.32.1.1 && print "172.32.1.1 is internal."

	exit 0
fi

arg0="$(basename "$0")"
if [[ "$1" == "-?" ]]
then
	print "Usage: $arg0 [time window] [trigger threshold]"
	exit 0
fi

if [[ ${2:-10} != +([[:digit:]]) ]]
then
	print "$2: Not a number." 1>&2
	exit 1
fi
typeset interval="${1:-2 hours}"
typeset since="$(get_past_time "now - $interval")" || exit 1
integer threshold="${2:-10}" || exit 1


# Set the checking window/duration in the line below.
journalctl --since="$since" -u ssh -g "Failed password" |
	awk '{print $(NF-3)}' |
	sort |
	uniq -c |
while read count address
do
	# If threshold hasn't been met, do nothing.
	(( count < threshold )) && continue
	# Sanity check the IP we got and output it as a candidate for addition
	if ! is_ip_address "$address"
	then
		print -- "$address: Not an IP address." 1>&2
	elif is_internal_ip "$address"
	then
		print -- "$address: Internal/private IP ignored." 1>&2
	else
		print "*: $address"
	fi
done | grep -Fvx -f /etc/hosts.deny
# That last grep prevents us from adding duplicate entries to /etc/hosts.deny.

