#!/usr/bin/env ksh
#
# Copyright (c) 2018-2021 Jordan Geoghegan <jordan@geoghegan.ca>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
# PERFORMANCE OF THIS SOFTWARE.

# Version 0.6-BETA "Tyranny of the Majority" -- Released TBD 00, 2022

# In loving memory of Ron and Jan Sather

# And then one day the happy answer came to me, my friends: there was no country for people like me.

# Fetch, parse and aggregate IP blocklist data into format suitable
# for ingestion into a PF firewall table.

readonly version='0.6-BETA_26'
readonly release_date='TBD'
readonly release_name='Tyranny of the Majority'

set -efo pipefail

# ------------------------------------------------------------------------------
# Abort Sequences and Housekeeping
# ------------------------------------------------------------------------------

CLEANUP() {
	if [[ -d ${tmpdir_main} ]]; then
		rm -rf -- "${tmpdir_main}" || WARN_ERR "ERROR: Failed to delete temporary directory: '${tmpdir_main}'"
	fi
}

ERR() {
	typeset sanitized
	sanitized="$(printf '%s' "$*" | tr '[:cntrl:]' '?')"
	WARN_ERR "ERROR: ${sanitized}"
	WARN_ERR 'Bailing out...'
	exit 1
}

HELP_MESSAGE() {
	printf 'usage:\n'
	printf 'pf-badhost [-4|-6|-B] [-ADVbnx] [-C config-file]\n'
	printf '           [-H maxauthtries [-E extract-tool -J log-file -K log-file]]\n'
	printf '           [-P maxprocs] [-R retrylimit] [-T allow|block|block-exit]\n'
	printf '           [-U useragent] [-W output-file] [-a asn] [-c ip-address]\n'
	printf '           [-f blocklist-file] [-g cctld] [-i ip-file] [-j asn-file]\n'
	printf '           [-l url-file] [-m asn-url-file] [-o long-option] [-r rule]\n'
	printf '           [-s cctld-file] [-t asn-url] [-u url] [-w rule-file]\n'
	printf '           [file ...]\n'
	printf 'pf-badhost -h | -v\n'
	exit 1
}

OLD_CONF_RESET() {
	WARN_ERR "ERROR: Invalid data in file: '${_OUTFILE}'"
	if ! cp -- "${tmp_oldconf}" "${_OUTFILE}"; then
		WARN_ERR "Failed to restore backup '${_OUTFILE}', zeroing out file!"
		cp -- /dev/null "${_OUTFILE}" || WARN_ERR "ERROR: Failed to zero out '${_OUTFILE}'"
		WARN_ERR 'ERROR: Bailing out without making changes...'
		exit 1
 	fi
	ERR "Restoring backup '${_OUTFILE}'"
}

STRICT_MODE_CHECK() {
	if ((_STRICT == 1)); then
		ERR 'Failed to download one or more blocklists!'
	fi
}

PF_ERR() {
	ERR 'Failed to retreive PF statistics via pfctl(1)!'
}

TMP_FILE_ERR() {
	ERR 'Failed to create or write to a temporary file!'
}

TMP_FILE_DEL_ERR() {
	ERR 'Failed to delete temporary files!'
}

WARNING() {
	if ((_VERBOSE == 0 && _LOG == 1)); then
		WARN_ERR "$*" >/dev/null 2>&1
	elif ((_VERBOSE == 1 && _LOG == 0)); then
		printf '\n%s\n\n' "$*" 1>&2
	elif ((_VERBOSE == 1 && _LOG == 1)); then
		WARN_ERR "$*"
	fi
}

WARN_ERR() {
	# Force printing and logging of error messages
	logger -st 'pf-badhost' -- "$*"
}

# ------------------------------------------------------------------------------
# Alias functions
# ------------------------------------------------------------------------------

myawk() {
	case "${awk_pref}" in
	    awk|gawk|goawk|mawk) nice -- "${awk_pref}" "$@" - ;;
	    *)  # If awk_pref override is unset, opportunistically use best available option.
		if command -v mawk >/dev/null ; then
			nice -- mawk "$@" -
		elif command -v gawk >/dev/null ; then
			nice -- gawk "$@" -
		elif command -v goawk >/dev/null ; then
			nice -- goawk "$@" -
		elif command -v awk >/dev/null ; then
			nice -- awk "$@" -
		else
			ERR 'No suitable awk utility found!'
		fi ;;
	esac
}

mygrep() {
	case "${grep_pref}" in
	    grep|ggrep|ugrep) nice -- "${grep_pref}" -Eh "$@" - || true ;;
	    rg) nice -- "${grep_pref}" -I "$@" - 2>/dev/null || true ;;
	    *)  # If grep_pref override is unset, opportunistically use best available option.
		if command -v rg >/dev/null ; then
			nice -- rg -I "$@" - || true
		elif command -v ugrep >/dev/null ; then
			nice -- ugrep -Eh "$@" - || true
		elif command -v ggrep >/dev/null ; then
			nice -- ggrep -Eh "$@" - || true
		elif command -v grep >/dev/null ; then
			nice -- grep -Eh "$@" - || true
		else
			ERR 'No suitable grep utility found!'
		fi ;;
	esac
}

# Opportunistically use GNU realpath/readlink if available (needed for MacOS support)
myrealpath() {
	if command -v realpath >/dev/null ; then
		realpath -- "$@"
	elif command -v grealpath >/dev/null ; then
		grealpath -- "$@"
	elif command -v greadlink >/dev/null ; then
		greadlink -f -- "$@"
	elif command -v readlink >/dev/null && [[ ${_OS_TYPE} != 'macos' ]] ; then
		readlink -f -- "$@"
	else
		ERR 'No suitable realpath(1) or readlink(1) utility found!'
	fi
}

# Opportunistically use GNU sort if available (needed for NetBSD and MacOS support)
mysort() {
	case "${sort_pref}" in
	    sort|gsort) nice -- "${sort_pref}" "$@" - ;;
	    *)  # If sort_pref override is unset, opportunistically use best available option.
		if command -v gsort >/dev/null ; then
			nice -- gsort "$@" -
		elif command -v sort >/dev/null ; then
			nice -- sort "$@" -
		else
			ERR 'No suitable sort utility found!'
		fi ;;
	esac
}

# Placeholder function to be overwritten by config file.
CUSTOM_LISTS() {
:
}

# ------------------------------------------------------------------------------
# Authlog Analysis Functions
# ------------------------------------------------------------------------------

readonly authlog_awk='
function log_line(curLine){
	# We need different regexes to accommodate password auth logging differences
	# Remember: In awk world non-zero exit codes mean success.
	if( noPass == 1 ) {
		if( curLine ~ /sshd.*Invalid user/ || curLine ~ /sshd.*PAM: Unknown user/ || curLine ~ /sshd.*(for|user) root / )
			return 1
	}
	else {
		if( curLine ~ /sshd.*Failed (none|password)/ || curLine ~ /sshd.*PAM: Authentication error/ )
			return 1
	}
}
function repeated(){
	# Account for repeat messages
	if( $0 ~ "last message repeated" ) {
		if( $7 == "repeated" )
			repeat=$8
		else if ( $8 == "repeated" )
			repeat=$9
		else {
			print "Failed to determine authlog repeat offset!" > "/dev/stderr"
			exit 1
		}
		# Make sure repeat is valid integer
		if( repeat !~ /^[[:digit:]]+$/ ) {
			print "Invalid number: \"" repeat "\"" > "/dev/stderr"
			exit 1
		}
		if ( log_line(prevLine) ) {
			for(i=0;i<repeat;i++)
				print prevLine
		}
	  }
}
{
gsub("nvalid user.*from", "nvalid user x from")
repeated()
# Save line in buffer to allow for repeats
prevLine=$0
# Print current line if it matches regex 
if ( log_line($0) )
	print $0
}'

# Hail Mary Mitigation preproccessor
AUTHLOG_PROC() {
	myawk -v noPass="${_SSH_NOPASS}" "${authlog_awk}" | tr '[:blank:]' '\n'
}

AUTHLOG_EXTRACT() {
	# 1st authlog path is guaranteed to exist
	if [[ -r ${authlog_path1} ]]; then
		"${authlog_unzip}" -f -- "${authlog_path1}" || return 1
	else
		"${getroot}" -- "${authlog_unzip}" -f "${authlog_path1}" || return 1
	fi
	# 2nd authlog path may not exist
	if [[ -f ${authlog_path2} && -r ${authlog_path2} ]]; then
		"${authlog_unzip}" -f -- "${authlog_path2}" || return 1
	elif [[ -f ${authlog_path2} && ! -r ${authlog_path2} ]]; then
		"${getroot}" -- "${authlog_unzip}" -f "${authlog_path2}" || return 1
	fi 
}

HAIL_MARY_MITIGATE() {
	# Generate blocklist from authlog
	if ((_IPV4 == 1)); then
		AUTHLOG_EXTRACT \
		    | AUTHLOG_PROC \
		    | PARSE_V4 \
		    | myawk -v MAX="${_SSH_LIMIT}" '{ a[$0]++ }END{ for(ip in a) if (a[ip] > MAX) print ip }' \
		    | mysort -uV
	fi || return 1
	if ((_IPV6 == 1)); then
		AUTHLOG_EXTRACT \
		    | AUTHLOG_PROC \
		    | PARSE_V6 \
		    | myawk -v MAX="${_SSH_LIMIT}" '{ a[$0]++ }END{ for(ip in a) if (a[ip] > MAX) print ip }' \
		    | mysort -uV
	fi || return 1
}

# ------------------------------------------------------------------------------
# Validator Functions and Input Sanitization
# ------------------------------------------------------------------------------

readonly ipv4_regex='((25[0-5]|(2[0-4]|1{0,1}[[:digit:]]){0,1}[[:digit:]])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[[:digit:]]){0,1}[[:digit:]])(/(3[0-2]|[1-2][[:digit:]]|[1-9]))?'
readonly ipv6_regex='(([[:xdigit:]]{1,4}:){7,7}[[:xdigit:]]{1,4}|([[:xdigit:]]{1,4}:){1,7}:|([[:xdigit:]]{1,4}:){1,6}:[[:xdigit:]]{1,4}|([[:xdigit:]]{1,4}:){1,5}(:[[:xdigit:]]{1,4}){1,2}|([[:xdigit:]]{1,4}:){1,4}(:[[:xdigit:]]{1,4}){1,3}|([[:xdigit:]]{1,4}:){1,3}(:[[:xdigit:]]{1,4}){1,4}|([[:xdigit:]]{1,4}:){1,2}(:[[:xdigit:]]{1,4}){1,5}|[[:xdigit:]]{1,4}:((:[[:xdigit:]]{1,4}){1,6})|:((:[[:xdigit:]]{1,4}){1,7}|:)|[fF][eE]80:(:[[:xdigit:]]{0,4}){0,4}%[[:alnum:]]{1,}|::([fF]{4}(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[[:digit:]]){0,1}[[:digit:]])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[[:digit:]]){0,1}[[:digit:]])|([[:xdigit:]]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[[:digit:]]){0,1}[[:digit:]])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[[:digit:]]){0,1}[[:digit:]]))(/(12[0-8]|1[0-1][[:digit:]]|[1-9][[:digit:]]{0,1}))?'
readonly awk_geo_parse='
	 BEGIN{ cmd = "mktemp -q " ASNDIR "/asn.XXXXXXX" ; if( ( cmd | getline result ) > 0 ) ASNFILE = result ; else exit 1 }
	 BEGIN{ cmd = "mktemp -q " LISTDIR "/tmp.XXXXXXX" ; if( ( cmd | getline result ) > 0 ) TMPFILE = result ; else exit 1 }
	 NR==FNR { A[$1]=1 ; next }
	 { if ( $1 in A )
		if ( IPV4 == 1 && $2 == "ipv4" ) printf("%s/%d\n", $3, 32-log($4)/log(2)) > TMPFILE ;
		else if ( IPV6 == 1 && $2 == "ipv6" ) printf("%s/%d\n", $3, $4) > TMPFILE ;
		else if ( $2 == "asn" )
			if ($3 <= 4294967295 && $3 >= 0) printf("AS%s\n", $3) > ASNFILE
			else printf("Skipping Invalid ASN: \"%s\"\n", $3) > "/dev/stderr"
	 }'

# Validate IPv4 addresses: pulls addresses from arbitrarily formatted text, thanks to "grep -o"
PARSE_V4() {
	mygrep -o "${ipv4_regex}"
}

# Validate IPv6 addresses (Addresses must be RFC-5952 Compliant and have one address per line)
# Some IPv6 address lists require a preprocessor to format the lists/addresses correctly for validation
PARSE_V6() {
	SANITIZE_ARRAY | mygrep -x "${ipv6_regex}"
}

# Same as 'PARSE_V4' function, except it requires exact line matches and also allows for negations
PARSE_RULES_V4() {
	SANITIZE_ARRAY | mygrep -x "!?${ipv4_regex}"
}

# Same as 'PARSE_V6' function, except it allows for negations
PARSE_RULES_V6() {
	SANITIZE_ARRAY | mygrep -x "!?${ipv6_regex}"
}

PARSE_RIR_DATASETS() {
	# Safely create local temporary dirs/files
	typeset tmp_cc
	tmp_cc="$(TMP_FILE_SCRATCH)"
	# Export country codes to file (for subsequent ingestion into awk array)
	printf '%s\n' "${_country_code[@]}" > "${tmp_cc}" || TMP_FILE_ERR
	# Process RIR dataset in parallel with find+xargs+awk
	# Note: awk script safely handles its own file generation and output.
	find "${tmpdir_geo}" -type f -print0 \
	| xargs -0 -n 1 -P "${_PARALLEL}" -- nice -- \
	  "${awk_pref}" -F '|' -v IPV4="${_IPV4}" -v IPV6="${_IPV6}" -v ASNDIR="${tmpdir_asn}" -v LISTDIR="${tmpdir_fetch}" "${awk_geo_parse}" "${tmp_cc}"
	# Delete local temp files
	rm -f -- "${tmp_cc}" || TMP_FILE_DEL_ERR
}

SANITIZE_ARRAY() {
	myawk '!/^([[:space:]]*|[[:cntrl:]]*)?(#|;)|^([[:space:]]*|[[:cntrl:]]*)$/ {gsub(/[[:cntrl:]]/,""); print $1}'
}

SANITIZE_ARRAY_SORTED() {
	SANITIZE_ARRAY | mysort -u
}

# ------------------------------------------------------------------------------
# List Generation Functions
# ------------------------------------------------------------------------------

LIST_GEN() {
	# Generate local temp files
	typeset tmp_parsed_v4 tmp_parsed_v6
	tmp_parsed_v4="$(TMP_FILE_SCRATCH)"
	tmp_parsed_v6="$(TMP_FILE_SCRATCH)"

	# Make sure there are no empty files in tmpdir_fetch
	find "${tmpdir_fetch}" -type f -size 0 -delete || TMP_FILE_DEL_ERR

	# Decompress, parse and sort list before potentially performing subnet aggregation
	if ((_IPV4 == 1)); then
		case "${grep_pref}" in
		    rg)    nice rg -ILo --no-ignore --no-ignore-files -- "${ipv4_regex}" "${tmpdir_fetch}" || true ;;
		    ugrep) nice ugrep -ERhoz -- "${ipv4_regex}" "${tmpdir_fetch}" || true ;;
		    *)     find -L "${tmpdir_fetch}" -type f -print0 | xargs -0n 1 -P "${_PARALLEL}" -- nice "${grep_pref}" -Eho --line-buffered -- "${ipv4_regex}" || true ;;
		esac | mysort -uV > "${tmp_parsed_v4}"
	fi
	if ((_IPV6 == 1)); then
		case "${grep_pref}" in
		    rg)    nice rg -ILx --crlf --no-ignore --no-ignore-files -- "${ipv6_regex}" "${tmpdir_fetch}" || true ;;
		    ugrep) ugrep -ERhxz -- "${ipv6_regex}" "${tmpdir_fetch}" || true ;;
		    *)     find -L "${tmpdir_fetch}" -type f -print0 | xargs -0n 1 -P "${_PARALLEL}" -- nice "${grep_pref}" -Ehx --line-buffered -- "${ipv6_regex}" || true ;;
		esac | tr '[:upper:]' '[:lower:]' | tr -d '\r' | mysort -uV > "${tmp_parsed_v6}"
	fi

	# Filter and Generate IP Address List
	if ((_AGGREGATE == 1)); then
		if ((_IPV4 == 1)); then
			case "${agg_pref}" in
			    aggregate)
				SUB_AGG_C < "${tmp_parsed_v4}" > "${tmp_v4_list}" || TMP_FILE_ERR ;;
			    aggregate6)
				SUB_AGG_PY < "${tmp_parsed_v4}" > "${tmp_v4_list}" || TMP_FILE_ERR ;;
			    aggy)
				SUB_AGG_GO < "${tmp_parsed_v4}" > "${tmp_v4_list}" || TMP_FILE_ERR ;;
			    perl)
				SUB_AGG_PERL < "${tmp_parsed_v4}" > "${tmp_v4_list}" || TMP_FILE_ERR ;;
			esac
		fi
		if ((_IPV6 == 1)); then
			SUB_AGG_6 < "${tmp_parsed_v6}" > "${tmp_v6_list}" || TMP_FILE_ERR
		fi
	else
		mv -- "${tmp_parsed_v4}" "${tmp_v4_list}" || TMP_FILE_ERR
		mv -- "${tmp_parsed_v6}" "${tmp_v6_list}" || TMP_FILE_ERR
	fi

	# Generate blocklist statistics
	stats="$(PRINT_STATS)"
	readonly stats

	# Delete local temp files
	rm -f -- "${tmp_parsed_v4}" "${tmp_parsed_v6}" || TMP_FILE_DEL_ERR

	{ PRINT_LIST | USER_FILTERING ; } > "${tmp_finout}" || TMP_FILE_ERR
}

LIST_INSTALL() {
	# Calculate byte offsets for cmp (strip date header)
	typeset new_offset old_offset tmp_oldconf
	new_offset="$(head -2 -- "${tmp_finout}" | wc -c)"
	old_offset="$(head -2 -- "${_OUTFILE}" | wc -c)"
	tmp_oldconf="$(TMP_FILE_SCRATCH)"

	# Reload pf-badhost table only if there are blocklist changes
	# 'cmp -s' on most platforms has a bug where it ignores byte offsets :(
	if cmp -- "${_OUTFILE}" "${tmp_finout}" "${old_offset}" "${new_offset}" >/dev/null 2>&1; then
		printf '\nNo blocklist changes...\n' 1>&2
		if ((_LOG == 1)); then
			{ printf '# Last Run (no changes): %s\n' "$(date)" ; \
			  cat -- < "${_OUTFILE}" ;} > "${tmp_oldconf}" || ERR 'Failed to update log file!'
			cp -- "${tmp_oldconf}" "${_LOGFILE}" || ERR 'Failed to update log file!'
			chmod 640 "${_LOGFILE}" >/dev/null 2>&1
		fi
		# Delete local temp file and return success
		rm -f -- "${tmp_oldconf}" || TMP_FILE_DEL_ERR
		return 0
	fi
	# Test pf.conf for syntax errors
	"${getroot}" -- "${_PFCTL}" -nf /etc/pf.conf || ERR 'Invalid PF config!'
	# Backup old blocklist and move newly generated blocklist into place
	cp -- "${_OUTFILE}" "${tmp_oldconf}" || TMP_FILE_ERR
	cp -- "${tmp_finout}" "${_OUTFILE}" || OLD_CONF_RESET
	# Reload the pfbadhost table
	"${getroot}" -- "${_PFCTL}" -t pfbadhost -T replace -f "${_OUTFILE}" || OLD_CONF_RESET
	if ((_LOG == 1)); then
		LOG_ROTATE
	fi
	# Delete local temp files
	rm -f -- "${tmp_oldconf}" || TMP_FILE_DEL_ERR
}

PRINT_LIST() {
	# Generate ${_OUTFILE} from newly processed blocklist data
	printf '%s\n' "${stats}" | sed 's/^/# /g'
	# User defined rules and address negation
	if ((${#_custom_rule[@]} > 0)); then
		printf '\n# User Defined Rules:\n\n'
		if ((_IPV4 == 1)); then
			for _i in "${_custom_rule[@]}"; do
				printf '%s\n' "${_i}"
			done | PARSE_RULES_V4 | mysort -uV
		fi
		if ((_IPV6 == 1)); then
			for _i in "${_custom_rule[@]}"; do
				printf '%s\n' "${_i}"
			done | PARSE_RULES_V6 | mysort -uV
		fi
	fi
	# Authlog Analysis
	if ((_HAIL_MARY == 1)); then
		printf '\n# Rules Generated from %s:\n\n' "$authlog_path1"
		cat -- < "${tmp_authlog}"
	fi
	# Tor Filtering
	if [[ -s ${tmp_tor} ]]; then
		if ((_TOR_FILTER == 1)); then
			printf '\n# Tor Whitelist:\n\n'
		else
			printf '\n# Tor Blacklist:\n\n'
		fi
		cat -- < "${tmp_tor}"
	fi
	# System rules (reserved address negation)
	if (((_IPV4 == 1 && _RESERVED_IPV4 == 1) || (_IPV6 == 1 && _RESERVED_IPV6 == 1))); then
		# Whitelist reserved address ranges for enabled address families
		printf '\n# System Rules:\n\n'
		((_IPV4 == 1 && _RESERVED_IPV4 == 1)) && printf '!%s\n' "${_reserved_ipv4[@]}"
		((_IPV6 == 1 && _RESERVED_IPV6 == 1)) && printf '!%s\n' "${_reserved_ipv6[@]}"
	fi
	# Main ruleset
	if [[ ${_IPV4} -eq 1 && -s ${tmp_v4_list} ]]; then
		printf '\n# IPv4 List Generated Rules:\n\n'
		cat -- < "${tmp_v4_list}"
	fi
	if [[ ${_IPV6} -eq 1 && -s ${tmp_v6_list} ]]; then
		printf '\n# IPv6 List Generated Rules:\n\n'
		cat -- < "${tmp_v6_list}"
	fi
}

TOR_FILTER() {
	# Safely create local temporary dirs/files
	typeset tmpdir_tor tmp_file
	tmpdir_tor="$(mktemp -d "${tmpdir_main}/tor.XXXXXX")" || TMP_FILE_ERR

	cd -- "${tmpdir_tor}" || TMP_FILE_ERR
	if ((_TOR_FILTER == 3)); then
		URL_FETCH 'https://github.com/SecOps-Institute/Tor-IP-Addresses/raw/master/tor-exit-nodes.lst'
	else
		URL_FETCH 'https://github.com/SecOps-Institute/Tor-IP-Addresses/raw/master/tor-nodes.lst'
	fi || ERR 'Failed to fetch Tor IP list!'
	cd -- "${tmpdir_work}" || TMP_FILE_ERR

	# Create Tor whitelist/blacklist
	tmp_file="$(find "${tmpdir_tor}" -type f)"
	[[ -f ${tmp_file} ]] || TMP_FILE_ERR
	if ((_TOR_FILTER == 1)); then
		((_IPV4 == 1)) && \
			PARSE_V4 < "${tmp_file}" | mysort -uV | sed 's/^/!/g'
		((_IPV6 == 1)) && \
			PARSE_V6 < "${tmp_file}" | mysort -uV | sed 's/^/!/g'
	else
		((_IPV4 == 1)) && \
			PARSE_V4 < "${tmp_file}" | mysort -uV
		((_IPV6 == 1)) && \
			PARSE_V6 < "${tmp_file}" | mysort -uV
	fi > "${tmp_tor}"
	# Delete local temp files
	rm -rf -- "${tmpdir_tor}" || TMP_FILE_DEL_ERR
}

USER_FILTERING() {
	if ((_CUSTOM_FILTER == 1)); then
		CUSTOM_FILTER
	else
		cat
	fi
}

# ------------------------------------------------------------------------------
# List Statistics and Totals
# ------------------------------------------------------------------------------

V4_TOTAL() {
	myawk -v v4num="${v4_num}" -v tornum="${tor_num4}" -F '/' '/\/[[:digit:]][[:digit:]]?$/ {sum+= 2^(32 - $2)} END{printf "%0.0f", sum + v4num + tornum}' < "${tmp_v4_list}"
}

V6_TOTAL() {
	myawk -v v6num="${v6_num}" -v tornum="${tor_num6}" -F '/' '/\/[[:digit:]][[:digit:]]?[[:digit:]]?$/ {sum+= 2^(128 - $2)} END{printf "%0.0f", sum + v6num + tornum}' < "${tmp_v6_list}"
}

PRINT_STATS() {
	# Print number of addresses in table (expand CIDR ranges)
	typeset authlog_num tor_num4 tor_num6 v4_num v4_total v6_num v6_total
	authlog_num="$(wc -l -- < "${tmp_authlog}" | tr -cd '[:digit:]')"

	printf 'Date Created: %s\n' "$(date)"

	if ((_HAIL_MARY == 1)); then
		printf '\nBruteforcers found in "%s":  %s\n' "${authlog_path1}" "${authlog_num}"
	else
		printf '\n'
	fi

	if ((${#_asn_array[@]} > 0)); then
		printf 'Filtered ASN:  %s\n' ${#_asn_array[@]}
	fi

	if ((_IPV4 == 1)); then
		if [[ -s ${tmp_tor} && ${_TOR_FILTER} == @(2|3) ]]; then
			tor_num4="$(mygrep -c '\.' < "${tmp_tor}")"
			IS_INT "${tor_num4}" || tor_num4=0
		else
			tor_num4=0
		fi
		v4_num="$(mygrep -cv '/[[:digit:]]{1,2}$' < "${tmp_v4_list}")"
		IS_INT "${v6_num}" || v6_num=0
		v4_total="$(V4_TOTAL)"
		printf 'IPv4 addresses in table:  %s\n' "${v4_total}"
	fi

	if ((_IPV6 == 1)); then
		if [[ -s ${tmp_tor} && ${_TOR_FILTER} == @(2|3) ]]; then
			tor_num6="$(mygrep -c ':' < "${tmp_tor}")"
			IS_INT "${tor_num6}" || tor_num4=0
		else
			tor_num6=0
		fi
		v6_num="$(mygrep -cv '/[[:digit:]]{1,3}$' < "${tmp_v6_list}")"
		IS_INT "${v6_num}" || v6_num=0
		v6_total="$(V6_TOTAL)"
		printf 'IPv6 addresses in table:  %s\n\n' "${v6_total}"
	else
		printf '\n\n'
	fi
}

# ------------------------------------------------------------------------------
# PF Table Statistics and Debugging
# ------------------------------------------------------------------------------

CHECK_ADDRESS() {
	set +e
	for _i in "${_check_addr[@]}"; do
		"${getroot}" -- "${_PFCTL}" -t pfbadhost -T test "${_i}" 2>/dev/null
		case $? in
			0) printf '%s\t# Found in table: YES\n' "${_i}" ;;
			2) printf '%s\t# Found in table: NO\n' "${_i}" ;;
			255) ERR "Invalid IP address supplied: '${_i}'" ;;
			*) ERR 'Failed to access PF table via pfctl(1)!' ;;
		esac
	done
	set -e
	exit 0
}

PRINT_DEBUG() {
	typeset awk_v fetch_v grep_v
	# Get utility version numbers
	case "${awk_pref}" in
	    awk|gawk) awk_v="$({ "${awk_pref}" -V || "${awk_pref}" --version ;} 2>/dev/null | head -1 | cut -d' ' -f3 | tr -cd '[:digit:].')" ;;
	    goawk) awk_v="$("${awk_pref}" --version | tr -cd '[:digit:].')" ;;
	    mawk)  awk_v="$({ "${awk_pref}" -W version 2>&1 || true ;}| head -1 | cut -d' ' -f2)" ;;
	esac || awk_v='unknown'

	case "${grep_pref}" in
	    grep|ggrep) grep_v="$("${grep_pref}" -V 2>&1 | myawk 'NR==1{print $NF}')" ;;
	    rg|ugrep)   grep_v="$("${grep_pref}" -V | head -1 | cut -d' ' -f2)" ;;
	esac || grep_v='unknown'

	case "${netget}" in
	    aria2c|wget) fetch_v="$("${netget}" --version | head -1 | cut -d' ' -f3)" ;;
	    curl) fetch_v="$("${netget}" --version | head -1 | cut -d' ' -f2)" ;;
	    fetch|ftp) fetch_v="$(uname -r)" ;;
	esac || fetch_v='unknown'

	printf '# Version Info: pf-badhost v%s - %s, %s\n' "${version}" "${release_name}" "${release_date}"

	printf '\n# Detected OS Type: %s\n' "${_OS_TYPE}"

	printf '\n# Path Info:\n'
	printf '\tOutput file: %s\n' "${_OUTFILE}"

	printf '\n# Utility Info:\n'
	printf '\tdoas/sudo:    %s\n' "$(command -v -- "${getroot}")"
	printf '\tawk utility:  %s (%s)\n' "$(command -v -- "${awk_pref}")" "${awk_v}"
	printf '\tgrep utility: %s (%s)\n' "$(command -v -- "${grep_pref}")" "${grep_v}"
	printf '\tURL fetch:    %s (%s)\n' "$(command -v -- "${netget}")" "${fetch_v}"
	if ((_AGGREGATE == 1)); then
		((_IPV4 == 1)) && \
			printf '\tIPv4 Aggregation Utility: %s\n' "$(command -v -- "${agg_pref}")"
		((_IPV6 == 1)) && \
			printf '\tIPv6 Aggregation Utility: %s\n' "$(command -v aggregate6)"
	fi

	printf '\n# Array Statistics:\n'
	printf '\tASN blocked:     %d\n' "${#_asn_array[@]}"
	printf '\tASN URL:         %d\n' "${#_asn_url[@]}"
	printf '\tBlocklist URL:   %d\n' "${#_blocklist_url[@]}"
	printf '\tCustom rules:    %d\n' "${#_custom_rule[@]}"
	printf '\tLocal blocklist: %d\n' "${#_file_array[@]}"

	if ((_GEOBLOCK == 1)); then
		printf '\n# Geoblocking Info:\n'
		printf '\tCountry codes blocked: %d\n' "${#_country_code[@]}"
	fi

	if ((_HAIL_MARY == 1)); then
		printf '\n# SSH Authlog Analysis Info:\n'
		printf '\tSSH authlog path #1: %s\n' "${authlog_path1}"
		printf '\tSSH authlog path #2: %s\n' "${authlog_path2}"
		printf '\tDecompression tool:  %s\n\n' "${authlog_unzip}"
	fi
}

PRINT_STATS_ADDRESS() {
	printf '# pf-badhost per-Address Statistics:\n\n'
	{ "${getroot}" -- "${_PFCTL}" -t pfbadhost -vT show || PF_ERR ;} | mygrep -B7 'Out/Pass'
}

PRINT_STATS_GLOBAL() {
	printf '# pf-badhost Global Table Statistics:\n\n'
	{ "${getroot}" -- "${_PFCTL}" -vvs Tables || PF_ERR ;} | mygrep -A13 'pfbadhost'
	echo ''
}

STATS_FILTER() {
	mygrep -Ev 'Match|Pass' | sed 's/Cleared:/Added:  /g'
}

# ------------------------------------------------------------------------------
# Blocklist Backup Functions
# ------------------------------------------------------------------------------

LOG_ROTATE() {
	typeset tmp_rotate
	tmp_rotate="$(TMP_FILE_SCRATCH)"
	# Gzip old log file
	gzip -9c < "${_LOGFILE}" > "${tmp_rotate}" || ERR 'Failed to rotate log file!'
	cp -- "${tmp_rotate}" "${_LOGGZIP}" || ERR 'Failed to create log file!'
	# Move new log into place
	cp -- "${tmp_finout}" "${_LOGFILE}" || ERR 'Failed to create log file!'
	chmod 640 "${_LOGFILE}" "${_LOGGZIP}" || ERR 'Failed to set permissions on log file'
	# Delete local temp files
	rm -f -- "${tmp_rotate}" || TMP_FILE_DEL_ERR
}

# ------------------------------------------------------------------------------
# Subnet Aggregation Functions
# ------------------------------------------------------------------------------

# Call "aggregate6" utility to aggregate IPv6 address list.
SUB_AGG_6() {
	nice aggregate6 -6t 2>/dev/null | sed 's/\/128$//g'
}

# Call "aggregate" utility to aggregate IPv4 address list.
# Run "pkg_add aggregate" on OpenBSD to install
# Very mature, stable code written in C
SUB_AGG_C() {
	nice aggregate -qtp32 | sed 's/\/32$//g'
}

# Call experimental "aggy" aggregate utility.
# Preliminary testing has shown it to be 100 to 1000 times faster than the alternatives
# See install instructions for info on how to install aggy
SUB_AGG_GO() {
	nice aggy | sed 's/\/32$//g'
}

# Call "aggregate6" utility to aggregate IPv4 address list.
# Run "pkg_add aggregate6" on OpenBSD to install or "pip3 install aggregate6" on other platforms
SUB_AGG_PY() {
	nice aggregate6 -4t 2>/dev/null | sed 's/\/32$//g'
}

# IPv4 CIDR/Address Aggregator [pure Perl version of "aggregate" utility]
_SUBNET_MERGE_PERL=$(cat <<'__EOT'
#!/usr/bin/perl -lp0a
$_=join$\,sort map{1x(s/\d*./unpack B8,chr$&/ge>4?$&:32)&$_}@F;1while s/^(.*)
\1.*/$1/m||s/^(.*)0
\1.$/$1/m;s!^.*!(join'.',map{ord}split'',pack B32,$&).'/'.length$&!gme
__EOT
)

# Pure Perl version of ISC "aggregate" utility [code stored in above variable "_SUBNET_MERGE_PERL"]
# WARNING: ~10x slower than C based utility and ~1000x slower than aggy
SUB_AGG_PERL() {
	nice -- perl -e "${_SUBNET_MERGE_PERL}" | sed 's/\/32$//g'
}

# ------------------------------------------------------------------------------
# Temp File Functions
# ------------------------------------------------------------------------------

TMP_FILE() {
	mktemp -- "${tmpdir_fetch}/tmp.XXXXXXXX" || TMP_FILE_ERR
}

TMP_FILE_SCRATCH() {
	mktemp -- "${tmpdir_scratch}/scratch.XXXXXXXX" || TMP_FILE_ERR
}

# ------------------------------------------------------------------------------
# Tests and Sanity Checks
# ------------------------------------------------------------------------------

CHECK_DISK() {
	# Make sure ${_OUTFILE} exists and is writable
	CHECK_W_PERM "${_OUTFILE}"
	# If logging is enabled, make sure files and permissions are correct
	if ((_LOG == 1)); then
		[[ -d ${_LOGDIR} ]] || ERR "Directory not found: '${_LOGDIR}'"
		[[ -r ${_LOGDIR} ]] || ERR "Read permission denied: '${_LOGDIR}'"
		CHECK_W_PERM "${_LOGFILE}" "${_LOGGZIP}"
	fi
}

CHECK_CMD() {
	typeset cmd
	for cmd in "$@"; do
		command -v -- "${cmd}" || ERR "Command not found: '${cmd}'"
	done >/dev/null
}

CHECK_PRIVILEGE() {
	[[ $(whoami) == "${_USER}" ]] || ERR "Script must be run as user '${_USER}'"
}

CHECK_R_PERM(){
	typeset file
	for file in "$@"; do
		[[ -f ${file} ]] || ERR "File not found: '${file}'"
		[[ -r ${file} ]] || ERR "Read permission denied: '${file}'"
	done
}

CHECK_W_PERM(){
	typeset file
	for file in "$@"; do
		[[ -f ${file} ]] || ERR "File not found: '${file}'"
		[[ -w ${file} ]] || ERR "Write permission denied: '${file}'"
	done
}

IS_ASN() {
	typeset num="${1#AS}"
	IS_INT "${num}" || return 1
	(((${#num} < ${#1}) && num >= 0 && num <= 4294967295)) || return 1
}

IS_BOOL() {
	case "$*" in
	    0|1) return 0 ;;
	    *) return 1 ;;
	esac
}

IS_CC() {
	typeset cc="$*"
	[[ ${#cc} -eq 2 && ${cc} == [[:upper:]][[:upper:]] ]] || return 1
}

IS_INT() {
	case "$*" in
	    ''|*[!0-9]*) return 1 ;;
	    *) return 0 ;;
	esac
}

PRE_EXEC_TESTS() {
	if ((_CHECK_UID == 1)); then
		CHECK_PRIVILEGE
	fi
	# Make sure requisite utilities and paths are available
	CHECK_CMD 'find' 'mktemp' 'nc' 'sed' 'split' 'xargs'
	((_EXPORT == 0)) && CHECK_DISK
	if ((_NEED_PFCTL == 1 || _EXPORT == 0)); then
		CHECK_CMD 'pfctl'
		_PFCTL="$(command -v pfctl)"
	fi
}

# Make sure user-provided values are sane
VAR_SANITY_CHECK() {
	typeset _i bool_cnt=0 int_cnt=0 need_root

	# Command line flag checks
	if ((_Eflag == 1 || _Jflag == 1 || _Kflag == 1)) && ((_Hflag == 0 && _HAIL_MARY == 0 )); then
		ERR '-E, -J, and -K options also require -H'
	fi

	# Integer checks
	for _i in "${_DEBUG_ACTION}" "${_PARALLEL}" "${_RETRY}" "${_SSH_LIMIT}" "${_TOR_FILTER}"
	do
		IS_INT "${_i}" || ERR "Internal accounting error: int_check: ${int_cnt}"
		((++int_cnt))
	done

	# Boolean checks
	for _i in "${_AGGREGATE}" "${_CHECK_UID}" "${_CUSTOM_FILTER}" "${_EXPORT}" "${_EXPORT_RAW}" \
		  "${_GEOBLOCK}" "${_HAIL_MARY}" "${_IPV4}" "${_IPV6}" "${_LOG}" "${_MIRROR}" "${_RESERVED_IPV4}" \
		  "${_RESERVED_IPV6}" "${_SSH_NOPASS}" "${_STRICT}" "${_USE_CONFIG}" "${_VERBOSE}"
	do
		IS_BOOL "${_i}" || ERR "Internal accounting error: bool_check: ${bool_cnt}"
		((++bool_cnt))
	done

	# Make sure user provided limits are greater than zero.
	((_SSH_LIMIT < 1)) && _SSH_LIMIT=1	
	((_PARALLEL < 1)) && _PARALLEL=1
	((_RETRY < 1)) && _RETRY=1

	# Make sure that at least one address family is enabled
	((_IPV4 == 0 && _IPV6 == 0)) && ERR 'No address family enabled! Please enable IPv4 and/or IPv6!'

	# Geoblocking sanity check
	((_GEOBLOCK == 1 && ${#_country_code[@]} == 0)) && ERR 'Geoblocking enabled, but no country codes specified!'

	# Set authlog related variables based on specified operating system
	if ((_HAIL_MARY == 1)); then
		((_Jflag == 1 && _Kflag == 0)) && authlog_path2='/dev/null'
		case "${_OS_TYPE}" in
		    dragonfly)
			[[ -n ${authlog_path1} ]] || authlog_path1='/var/log/auth.log'
			[[ -n ${authlog_path2} ]] || authlog_path2='/var/log/auth.log.0.gz'
			[[ -n ${authlog_unzip} ]] || authlog_unzip="$(command -v zcat)"	;;
		    freebsd)
			[[ -n ${authlog_path1} ]] || authlog_path1='/var/log/auth.log'
			[[ -n ${authlog_path2} ]] || authlog_path2='/var/log/auth.log.0.bz2'
			[[ -n ${authlog_unzip} ]] || authlog_unzip="$(command -v bzcat)" ;;
		    netbsd|openbsd)
			[[ -n ${authlog_path1} ]] || authlog_path1='/var/log/authlog'
			[[ -n ${authlog_path2} ]] || authlog_path2='/var/log/authlog.0.gz'
			[[ -n ${authlog_unzip} ]] || authlog_unzip="$(command -v zcat)"	;;
		    *)
			if [[ -z ${authlog_path1} || -z ${authlog_path2} || -z ${authlog_unzip} ]]; then
				WARNING "Unsupported OS Detected: (${_OS_TYPE})"
				ERR 'SSH authlog analysis with an unsupported OS requires the -E, -J, and -K options'
			fi ;;
		esac
		CHECK_CMD "${authlog_unzip}"
		[[ -f ${authlog_path1} ]] || ERR 'No SSH authlog found!'
		[[ -f ${authlog_path2} ]] || authlog_path2='/dev/null'
	fi

	# Determine whether we need root for anything
	if ((_EXPORT == 0 || _NEED_PFCTL == 1 || _DEBUG_ACTION == 5)) || [[ _HAIL_MARY -eq 1 && (! -r ${authlog_path1} || ! -r ${authlog_path2}) ]]; then
		need_root=1
	else
		need_root=0
	fi

	# Set doas/sudo path
	if [[ -z ${getroot} ]]; then
		if ((need_root == 1)); then
			if command -v doas ; then
				getroot="$(command -v doas)"
			elif command -v sudo ; then
				getroot="$(command -v sudo)"
			else
				ERR 'doas and/or sudo not found!'
			fi
		else
			getroot='false'
		fi
	else
		CHECK_CMD "${getroot}"
	fi

	# Pick the best available fetch utility if not specified
	if [[ -z ${netget} ]]; then
		if command -v aria2c ; then
			netget='aria2c'
		elif command -v wget ; then
			netget='wget'
		elif command -v curl ; then
			netget='curl'
		elif command -v fetch ; then
			netget='fetch'
		elif command -v ftp ; then
			netget='ftp'
		else
			ERR 'No suitable fetch utility found! Please install aria2c, curl or wget!'
		fi
	else
		CHECK_CMD "${netget}"
	fi

	# Pick the best available grep utility if not specified
	if [[ -z ${grep_pref} ]]; then
		if command -v rg ; then
			grep_pref='rg'
		elif command -v ugrep ; then
			grep_pref='ugrep'
		elif command -v ggrep ; then
			grep_pref='ggrep'
		elif command -v grep ; then
			grep_pref='grep'
		else
			ERR 'No suitable grep utility found!'
		fi
	else
		CHECK_CMD "${grep_pref}"
	fi

	# Pick the best available awk utility if not specified
	if [[ -z ${awk_pref} ]]; then
		if command -v mawk ; then
			awk_pref='mawk'
		elif command -v gawk ; then
			awk_pref='gawk'
		elif command -v goawk ; then
			awk_pref='goawk'
		elif command -v awk ; then
			awk_pref='awk'
		else
			ERR 'No suitable awk utility found!'
		fi
	else
		CHECK_CMD "${awk_pref}"
	fi

	# Pick the best available sort utility
	if [[ -z ${sort_pref} ]]; then
		if command -v gsort ; then
			sort_pref='gsort'
		elif command -v sort ; then
			sort_pref='sort'
		else
			ERR 'No suitable sort utility found!'
		fi
	else
		CHECK_CMD "${sort_pref}"
	fi

	# Pick the best available aggregation utility
	if ((_AGGREGATE == 1)); then
		if ((_IPV4 == 1)); then
			if command -v aggy ; then
				agg_pref='aggy'
			elif command -v aggregate ; then
				agg_pref='aggregate'
			elif command -v aggregate6 ; then
				agg_pref='aggregate6'
			elif command -v perl ; then
				agg_pref='perl'
			else
				ERR 'No suitable IPv4 subnet aggregation utility found!'
			fi
		fi
		if ((_IPV6 == 1)); then
			command -v aggregate6 || ERR 'No suitable IPv6 subnet aggregation utility found!'
		fi
	fi

}

# ------------------------------------------------------------------------------
# Fetch Functions
# ------------------------------------------------------------------------------

ASN_FETCH() {
	typeset _asn
	{ echo '!!'
	  if ((_IPV4 == 1)); then
	  	for _asn in "${_asn_array[@]}"; do
			echo "!g${_asn}"
		done
	  fi
	  if ((_IPV6 == 1)); then
	  	for _asn in "${_asn_array[@]}"; do
			echo "!6${_asn}"
		done
	  fi
	  echo 'q' ;} | { nc -w 15 whois.radb.net 43 || return 1 ;} | tr '[:space:]' '\n'
}

# This function can accept an arbitrary number of blocklist URL's as an argument.
# Because it's a function, there is no argument length limit.
# Use '-o' long options to set fetch utility preference.
URL_FETCH() {
	[[ -n $* ]] || ERR 'No URL Provided to fetch function!'

	if [[ ${netget} == 'aria2c' ]]; then
		for _i in "$@" ; do printf '%s\n' "${_i}" ; done \
		| nice -- aria2c \
			--auto-file-renaming=true \
			--http-accept-gzip=true \
			--input-file='-' \
			--max-concurrent-downloads=20 \
			--max-file-not-found="${_RETRY}" \
			--max-tries="${_RETRY}" \
			--min-split-size=5M \
			--no-conf=true \
			--no-netrc=true \
			--quiet=true \
			--retry-wait=15 \
			--save-not-found=false \
			--stop=900 \
			--user-agent="${_AGENT}"
	elif [[ ${netget} == 'wget' ]]; then
		for _i in "$@" ; do printf '%s\n' "${_i}" ; done \
		| nice -- wget \
			--compression=auto \
			--connect-timeout=45 \
			--input-file='-' \
			--no-cookies \
			--no-glob \
			--quiet \
			--read-timeout=900 \
			--tries="${_RETRY}" \
			--user-agent="${_AGENT}"
	# Curl, fetch and ftp(1) aren't capable of outputting to unique file names, so do it for them.
	elif [[ ${netget} == @(curl|fetch|ftp) ]]; then
		typeset uniq_string
		uniq_string=$(UNIQUE_STRING) || ERR 'UNIQUE_STRING(): Failed to generate unique file name!'
		case "${netget}" in
		    curl) for _i in "$@" ; do printf 'url = %s\n' "${_i}" ; done \
			| nice -- curl \
				--compressed \
				--config '-' \
				--connect-timeout 45 \
				--fail \
				--fail-early \
				--globoff \
				--location \
				--max-time 900 \
				--output '-' \
				--retry "${_RETRY}" \
				--retry-connrefused \
				--show-error \
				--silent \
				--ssl \
				--user-agent "${_AGENT}" ;;
		    fetch) for _i in "$@" ; do printf '%s\0' "${_i}" ; done \
				| xargs -0 -- nice -- fetch -aqo - -w 15 -T 45 --user-agent="${_AGENT}" -- ;;
		    ftp)   for _i in "$@" ; do printf '%s\0' "${_i}" ; done \
				| xargs -0 -- nice -- ftp -aigo - -w 45 -MVU "${_AGENT}" --  ;;
		esac | split -l 50000 - "${uniq_string}"
	fi || STRICT_MODE_CHECK
}

UNIQUE_STRING() {
	# find will return 1 every time it finds a file containing $string in current working directory.
	# Once we've generated a unique string, the 'until' loop will break.
	typeset counter=0 string="$RANDOM$RANDOM"
	until find . -type f -name "*${string}*" -exec false {} + ; do
		((++counter < 100)) || return 1
		string="$RANDOM$RANDOM"
	done
	printf '%s\n' "${string}"
}

# ------------------------------------------------------------------------------
# Main Function
# ------------------------------------------------------------------------------

main() {
	### Constants:
	# Set output path and user vars
	readonly _LOGDIR='/var/log/pf-badhost'
	readonly _LOGFILE="${_LOGDIR}/pf-badhost.log"
	readonly _LOGGZIP="${_LOGDIR}/pf-badhost.log.0.gz"
	readonly _USER='_pfbadhost'
	 _OUTFILE='/etc/pf-badhost.txt' # Can be changed via '-W'

	# Detect OS type
	typeset -l _OS_TYPE
	_OS_TYPE="$(uname -s)" || WARNING 'Failed to determine operating system type!'
	readonly _OS_TYPE
	# NetBSD uses non-standard folders in its PATH
	[[ ${_OS_TYPE} == 'netbsd' ]] && PATH="/usr/pkg/bin:/usr/pkg/sbin:${PATH}"

	# Reserved IPv4
	for _i in '0.0.0.0/8' '10.0.0.0/8' '100.64.0.0/10' '127.0.0.0/8' '169.254.0.0/16' '172.16.0.0/12' '192.0.0.0/24' \
		  '192.0.2.0/24' '192.31.196.0/24' '192.52.193.0/24' '192.88.99.0/24' '192.168.0.0/16' '192.175.48.0/24' \
		  '198.18.0.0/15' '198.51.100.0/24' '203.0.113.0/24' '240.0.0.0/4' ; do
		_reserved_ipv4[_index4++]="${_i}"
	done
	# Reserved IPv6
	for _i in '::/127' '::ffff:0:0/96' '64:ff9b::/96' '64:ff9b:1::/48' '100::/64' '2001::/23' '2001:db8::/32' \
		  '2002::/16' '2620:4f:8000::/48' 'fc00::/7' 'fe80::/10' 'ff00::/8' ; do
		_reserved_ipv6[_index6++]="${_i}"
	done
	# RIR dataset URLs
	for _i in 'afrinic.net/pub/stats/afrinic' 'apnic.net/stats/apnic' 'arin.net/pub/stats/arin' \
		  'lacnic.net/pub/stats/lacnic' 'ripe.net/ripe/stats' ; do
		_rir="${_i%%\.net*}" ; ((_count == 4)) && _rir='ripencc'
		_rir_mir[_count]="https://raw.githubusercontent.com/essays-on-esotericism/rir-mirror/main/delegated-${_rir}-extended-latest"
		_rir_url[_count++]="https://ftp.${_i}/delegated-${_rir}-extended-latest"
	done
	# Mark constants as read-only
	readonly _SUBNET_MERGE_PERL _reserved_ipv4 _reserved_ipv6 _rir_mir _rir_url
	unset _count _i _index4 _index6 _rir # clear counters

	### Variable declarations:
	# Initialize index / counters
	typeset _a_counter=0 _c_counter=0 _f_counter=0 _g_counter=0 _l_counter=0 _m_counter=0 _r_counter=0
	# Initialize case (in)sensitive vars
	typeset -u _asn _cc
	typeset -l  _opt_arg
	# Initialize local iterators
	typeset _abs_path _i
	# Initialize command line flag vars
	typeset _46Bflag=0 _Eflag=0 _Hflag=0 _Jflag=0 _Kflag=0 _Tflag=0 _bflag=0

	# Initialize global configuration vars (these values may be overwritten by command-line or config-file)
	_AGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0'
	_AGGREGATE=0 ; _CHECK_UID=1 ; _CUSTOM_FILTER=0 ; _DEBUG_ACTION=0 ; _EXPORT=0
	_EXPORT_RAW=0 ; _GEOBLOCK=0 ; _HAIL_MARY=0 ; _IPV4=1 ; _IPV6=0 ; _LOG=1
	_MIRROR=0 ;_PARALLEL=3 ; _RESERVED_IPV4=1 ; _RESERVED_IPV6=1 ; _RETRY=5
	_SSH_LIMIT=25 ; _SSH_NOPASS=0 ; _STRICT=1 ; _TOR_FILTER=0 ; _USE_CONFIG=0
	_VERBOSE=1

	# Command-line option handling
	while getopts :46ABC:DE:H:J:K:P:R:T:U:VW:a:bc:f:g:hi:j:l:nm:o:r:s:t:u:vw:x _opts ; do
	case "${_opts}" in
	    4)	# Force IPv4-only mode
		((++_46Bflag == 1)) || ERR 'Cannot combine -4, -6, and -B options'
		_IPV4=1 ; _IPV6=0 ;;
	    6)  # Force IPv6-only mode
		((++_46Bflag == 1)) || ERR 'Cannot combine -4, -6, and -B options'
		_IPV4=0 ; _IPV6=1 ;;
	    A)  # Enable subnet aggregation
		_AGGREGATE=1 ;;
	    B)  # Force both address families
		((++_46Bflag == 1)) || ERR 'Cannot combine -4, -6, and -B options'
		_IPV4=1 ; _IPV6=1 ;;
	    C)  # Load config file
		CHECK_R_PERM "${OPTARG}"
		# shellcheck source=/dev/null
		. "${OPTARG}"
		_USE_CONFIG=1 ;;
	    D)  # Disable UID checking
		_CHECK_UID=0 ;;
	    E)  # Set tool to unzip authlog
		CHECK_CMD "${OPTARG}"
		((++_Eflag == 1)) || ERR '-E: Can only be specified once'
		authlog_unzip="${OPTARG}" ;;
	    H)  # Enable SSH authlog analysis
		IS_INT "${OPTARG}" || ERR "-H: Invalid argument: '${OPTARG}'"
		_SSH_LIMIT="${OPTARG}"
		((++_Hflag == 1)) || ERR '-H: Can only be specified once'
		_HAIL_MARY=1 ;;
	    J)  # Set primary SSH authlog path
		_abs_path="$(myrealpath "${OPTARG}" 2>/dev/null)" || true
		[[ -f ${OPTARG} && -f ${_abs_path} ]] || ERR "-J: File not found: '${OPTARG}'"
		((++_Jflag == 1)) || ERR '-J: Can only be specified once'
		authlog_path1="${_abs_path}" ;;
	    K)  # Set secondary SSH authlog path
		_abs_path="$(myrealpath "${OPTARG}" 2>/dev/null)" || true
		[[ -f ${OPTARG} && -f ${_abs_path} ]] || ERR "-K: File not found: '${OPTARG}'"
		((++_Kflag == 1)) || ERR '-K: Can only be specified once'
		authlog_path2="${_abs_path}" ;;
	    P)  # Set max parallel jobs
		IS_INT "${OPTARG}" || ERR "-P: Invalid argument: '${OPTARG}'"
		_PARALLEL="${OPTARG}" ;;
	    R)  # Set max retries
		IS_INT "${OPTARG}" || ERR "-R: Invalid argument: '${OPTARG}'"
		_RETRY="${OPTARG}" ;;
	    T)  # Tor Filtering
		case "${OPTARG}" in
		    allow) _TOR_FILTER=1 ;;
		    block) _TOR_FILTER=2 ;;
		    block-exit) _TOR_FILTER=3 ;;
		    *) ERR "-T: Invalid argument: '${OPTARG}'" ;;
		esac
		((++_Tflag == 1)) || ERR '-T: Options are mutually exclusive' ;;
	    U)  # Set URL fetch utility user agent
		_AGENT="${OPTARG}" ;;
	    V)  # Disable printing of warning messages
		_VERBOSE=0 ;;
	    W)  # Set blocklist output path
		CHECK_W_PERM "${OPTARG}"
		_OUTFILE="${OPTARG}" ;;
	    a)  # Filter single ASN
		_asn="${OPTARG}"
		IS_ASN "${_asn}" || ERR "Invalid ASN: '${OPTARG}'"
		_asn_array[_a_counter++]="${_asn}" ;;
	    b)  # Bogon Filtering
		_bflag=1 ;;
	    c)  # Check if single IP/CIDR is in blocklist
		_CHECK_UID=0 ; _DEBUG_ACTION=4 ; _EXPORT=1
		_LOG=0 ; _NEED_PFCTL=1
		_check_addr[_c_counter++]="${OPTARG}" ;;
	    f)  # Use blocklist from local file system
		_abs_path="$(myrealpath "${OPTARG}" 2>/dev/null)" || true
		CHECK_R_PERM "${OPTARG}" "${_abs_path}"
		_file_array[_f_counter++]="${_abs_path}" ;;
	    g)  # Geo-blocking country by ISO3166 code
		_cc="${OPTARG}"
		IS_CC "${_cc}" || ERR "Invalid country code: '${OPTARG}'"
		_country_code[_g_counter++]="${_cc}"
		_GEOBLOCK=1 ;;
	    h)  # Print help message and exit
		HELP_MESSAGE ;;
	    i)  # Check if list of address/CIDR are in blocklist (bulk)
		_CHECK_UID=0 ; _DEBUG_ACTION=4 ; _EXPORT=1
		_LOG=0 ; _NEED_PFCTL=1
		_abs_path="$(myrealpath "${OPTARG}" 2>/dev/null)" || true
		CHECK_R_PERM "${OPTARG}" "${_abs_path}"
		for _i in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			_check_addr[_c_counter++]="${_i}"
		done ;;
	    j)  # Filter bulk ASN from local list
		CHECK_R_PERM "${OPTARG}"
		for _asn in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			IS_ASN "${_asn}" || ERR "Invalid ASN: '${_asn}'"
			_asn_array[_a_counter++]="${_asn}"
		done ;;
	    l)  # Add IP blocklist URLs in bulk from local list
		CHECK_R_PERM "${OPTARG}"
		for _i in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			_blocklist_url[_l_counter++]="${_i}"
		done ;;
	    m)  # Add ASN blocklist URLs in bulk from local list
		CHECK_R_PERM "${OPTARG}"
		for _i in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			_asn_url[_m_counter++]="${_i}"
		done ;;
	    n)  # Dry run test, do not actually execute
		_DEBUG_ACTION=6 ;;
	    o)  # Long form runtime options
		_opt_arg="${OPTARG}"
		case "${_opt_arg}" in
		    # Debugging options
		    print-debug)
			_CHECK_UID=0 ; _DEBUG_ACTION=5
			_EXPORT=1 ; _LOG=0 ;;
		    # PF table interactions
		    flush-table)
			_CHECK_UID=0 ; _EXPORT=1 ; _LOG=0
			_NEED_PFCTL=1 ; _DEBUG_ACTION=7 ;;
		    show-stats) 
			_CHECK_UID=0 ; _EXPORT=1 ; _LOG=0
			 _NEED_PFCTL=1 ; _DEBUG_ACTION=1 ;;
		    show-stats-address) 
			_CHECK_UID=0 ; _EXPORT=1; _LOG=0
			_NEED_PFCTL=1 ; _DEBUG_ACTION=2 ;;
		    show-stats-global) 
			_CHECK_UID=0 ; _EXPORT=1; _LOG=0
			_NEED_PFCTL=1 ; _DEBUG_ACTION=3 ;;
		    # Configuration options
		    custom-filter) _CUSTOM_FILTER=1 ;;
		    no-custom-filter) _CUSTOM_FILTER=0 ;;
		    ipv4-reserved) _RESERVED_IPV4=1 ;;
		    no-ipv4-reserved) _RESERVED_IPV4=0 ;;
		    ipv6-reserved) _RESERVED_IPV6=1 ;;
		    no-ipv6-reserved) _RESERVED_IPV6=0 ;;
		    log) _LOG=1 ;;
		    no-log) _LOG=0 ;;
		    mirror) _MIRROR=1 ;;
		    no-mirror) _MIRROR=0 ;;
		    ssh-nopass) _SSH_NOPASS=1 ;;
		    strict) _STRICT=1 ;;
		    no-strict) _STRICT=0 ;;
		    uid-check) _CHECK_UID=1 ;;
		    no-uid-check) _CHECK_UID=0 ;;
		    verbose) _VERBOSE=1 ;;
		    no-verbose) _VERBOSE=0 ;;
		    # Awk utility preferences
		    awk|gawk|goawk|mawk)
			CHECK_CMD "${_opt_arg}"
			awk_pref="${_opt_arg}" ;;
		    # Fetch Utility preferences
		    aria2c|curl|fetch|ftp|wget)
			CHECK_CMD "${_opt_arg}"
			netget="${_opt_arg}" ;;
		    # Grep utility preferences
		    grep|ggrep|ugrep|rg)
			CHECK_CMD "${_opt_arg}"
			grep_pref="${_opt_arg}" ;;
		    # doas/sudo preference
		    doas|sudo)
			CHECK_CMD "${_opt_arg}"
			getroot="${_opt_arg}" ;;
		    # Formatting Options
		    export-raw)
			_EXPORT=1 ; _EXPORT_RAW=1 ; _CHECK_UID=0 ; _LOG=0
			_RESERVED_IPV4=0 ; _RESERVED_IPV6=0 ; _VERBOSE=0 ;;
		    *) ERR "-o: Invalid argument: '${OPTARG}'" ;;
		esac
		;;
	    r)  # Add custom rules
		_custom_rule[_r_counter++]="${OPTARG}" ;;
	    s)  # Geo-block ISO3166 country codes from a file
		CHECK_R_PERM "${OPTARG}"
		_GEOBLOCK=1
		for _cc in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			IS_CC "${_cc}" || ERR "Invalid country code: '${_cc}'"
			_country_code[_g_counter++]="${_cc}"
		done ;;
	    t)	# Add single ASN blocklist URL
		_asn_url[_m_counter++]="${OPTARG}" ;;
	    u)	# Add single IP blocklist URL
		_blocklist_url[_l_counter++]="${OPTARG}" ;;
	    v)  # Print version information and exit
		printf 'pf-badhost %s (%s) Released on: %s\n' "${version}" "${release_name}" "${release_date}"
		exit 0 ;;
	    w)  # Add custom user rules in bulk from local list
		CHECK_R_PERM "${OPTARG}"
		for _i in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			_custom_rule[_r_counter++]="${_i}"
		done ;;
	    x)  # Export generated blocklist to stdout
		_EXPORT=1 ; _CHECK_UID=0 ; _LOG=0 ;;
	    :)  ERR "-${OPTARG}: requires argument" ;;
	    ?)  ERR "-${OPTARG}: unknown option" ;;
    	esac
	done
	# Treat all non-option arguments as files:
	shift $((OPTIND - 1))
	if (( ${#@} > 0)); then
		for _i in "$@" ; do
			_abs_path="$(myrealpath "${_i}" 2>/dev/null)" || true
			CHECK_R_PERM "${_i}" "${_abs_path}"
			_file_array[_f_counter++]="${_abs_path}"
		done
	fi

	# Mark config file and commandline sourced values as readonly
	readonly _AGENT _AGGREGATE _CHECK_UID _CUSTOM_FILTER _DEBUG_ACTION \
		 _EXPORT _EXPORT_RAW _GEOBLOCK _HAIL_MARY _IPV4 _IPV6 _LOG \
		 _MIRROR _RESERVED_IPV4 _RESERVED_IPV6 _STRICT _SSH_NOPASS \
		 _TOR_FILTER _USE_CONFIG _VERBOSE
	# Mark output path as readonly
	readonly _OUTFILE

	# Ensure conditions and user-provided values are sane
	VAR_SANITY_CHECK >/dev/null
	PRE_EXEC_TESTS >/dev/null
	# These vars are marked late because above sanity checks may modify them
	readonly _PARALLEL _PFCTL _RETRY _SSH_LIMIT authlog_path1 authlog_path2 \
		 authlog_unzip agg_pref awk_pref grep_pref getroot netget sort_pref

	# PF table manipulation and debugging output
	case "${_DEBUG_ACTION}" in
		1) # Print global and per-address statistics
		   { printf '# pf-badhost Statistics: [%s]\n\n' "$(date)" \
		     PRINT_STATS_GLOBAL ; \
		     PRINT_STATS_ADDRESS ;} | STATS_FILTER ;;
		2) # Print per-address statistics
		   printf '# pf-badhost Statistics: [%s]\n\n' "$(date)"
		   PRINT_STATS_ADDRESS | STATS_FILTER ;;
		3) # Print global statistics
		   printf '# pf-badhost Statistics: [%s]\n\n' "$(date)"
		   PRINT_STATS_GLOBAL | STATS_FILTER ;;
		4) # Check if addresses exist within PF table
		   CHECK_ADDRESS ;;
		5) # Print utility debug info
		   PRINT_DEBUG ;;
		6) # Dry run / config test
		   printf '\n# Configuration: ok\n' ;;
		7) # Flush PF table (disable pf-badhost filtering)
		   "${getroot}" -- "${_PFCTL}" -t pfbadhost -T flush || ERR 'Failed to flush PF table!' ;;
	esac
	((_DEBUG_ACTION > 0)) && exit 0

	# Add bogon filtering URL to array
	if ((_bflag == 1)); then
		((_IPV4 == 1)) && \
			_blocklist_url[_l_counter++]='https://www.team-cymru.org/Services/Bogons/fullbogons-ipv4.txt'
		((_IPV6 == 1)) && \
			_blocklist_url[_l_counter++]='https://www.team-cymru.org/Services/Bogons/fullbogons-ipv6.txt'
	fi

	# Mark array data as read-only
	readonly _asn_url _blocklist_url _country_code _custom_rule _file_array

	# Nuke unneeded variables
	unset _46Bflag _Eflag _Hflag _Jflag _Kflag _Tflag _bflag _abs_path _cc _opt_arg \
	      _c_counter _f_counter _g_counter _l_counter _m_counter _r_counter

	# Set trap handler
	trap "CLEANUP" 0 1 2 3 15

	# Safely create global temporary directories
	tmpdir_main="$(mktemp -d)" || TMP_FILE_ERR
	tmpdir_geo="$(mktemp -d "${tmpdir_main}/geo.XXXXXX")" || TMP_FILE_ERR
	tmpdir_fetch="$(mktemp -d "${tmpdir_main}/fetch.XXXXXX")" || TMP_FILE_ERR
	tmpdir_scratch="$(mktemp -d "${tmpdir_main}/scratch.XXXXXX")" || TMP_FILE_ERR
	tmpdir_work="$(mktemp -d "${tmpdir_main}/work.XXXXXX")" || TMP_FILE_ERR

	# Safely create global temporary files
	tmp_authlog="$(TMP_FILE_SCRATCH)"
	tmp_finout="$(TMP_FILE_SCRATCH)"
	tmp_tor="$(TMP_FILE_SCRATCH)"
	tmp_v4_list="$(TMP_FILE_SCRATCH)"
	tmp_v6_list="$(TMP_FILE_SCRATCH)"

	# Mark global temporary directory and file locations as read-only
	readonly tmpdir_main tmpdir_geo tmpdir_fetch tmpdir_scratch \
		 tmpdir_work tmp_authlog tmp_finout tmp_tor \
		 tmp_v4_list tmp_v6_list

	# Set working directory
	cd -- "${tmpdir_work}" || TMP_FILE_ERR
	
	# Create symbolic links to blocklists accessible on local filesystem 
	# (avoids duplicating potentially huge blocklists)
	if ((${#_file_array[@]} > 0)); then
		typeset _uniq
		for _i in "${_file_array[@]}"; do
			_uniq="$(mktemp -- "${tmpdir_fetch}/local.XXXXXXXX")" || TMP_FILE_ERR
			ln -sf -- "${_i}" "${_uniq}" || TMP_FILE_ERR
		done
		unset _uniq
	fi

	# Fetch blocklist URLs
	if ((${#_blocklist_url[@]} > 0)); then
		cd -- "${tmpdir_fetch}" || TMP_FILE_ERR
		URL_FETCH "${_blocklist_url[@]}"
		cd -- "${tmpdir_work}" || TMP_FILE_ERR
	fi

	# Fetch remote ASN blocklists
	if ((${#_asn_url[@]} > 0)); then
		# Safely create local temporary dirs/files and fetch
		typeset tmpdir_asn_fetch
		tmpdir_asn_fetch="$(mktemp -d "${tmpdir_main}/asnfetch.XXXXXX")" || TMP_FILE_ERR
		cd -- "${tmpdir_asn_fetch}" || TMP_FILE_ERR
		URL_FETCH "${_asn_url[@]}"
		# Parse ASN data and add to asn array
		cd -- "${tmpdir_work}" || TMP_FILE_ERR
		for _asn in $(find "${tmpdir_asn_fetch}" -type f -exec cat -- {} + | SANITIZE_ARRAY_SORTED); do
			IS_ASN "${_asn}" || ERR "Invalid ASN: '${OPTARG}'"
			_asn_array[_a_counter++]="${_asn}"
		done
		# Nuke local temp files
		rm -rf -- "${tmpdir_asn_fetch}" || TMP_FILE_DEL_ERR
		unset tmpdir_asn_fetch
	fi

	# Run Geoblock function if enabled
	if ((_GEOBLOCK == 1)); then
		# Safely create local temporary dirs/files
		typeset tmpdir_asn tmpdir_rir
		tmpdir_asn="$(mktemp -d "${tmpdir_main}/asn.XXXXXX")" || TMP_FILE_ERR
		tmpdir_rir="$(mktemp -d "${tmpdir_main}/rir.XXXXXX")" || TMP_FILE_ERR

		# Fetch registrar datasets
		cd -- "${tmpdir_rir}" || TMP_FILE_ERR
		if ((_MIRROR == 1)); then
			URL_FETCH "${_rir_mir[@]}"
		else
			URL_FETCH "${_rir_url[@]}"
		fi

		# Pre-process registrar datasets
		cd -- "${tmpdir_geo}" || TMP_FILE_ERR
		find "${tmpdir_rir}" -type f -exec cut -sd '|' -f 2-5 -- {} + | nice -- split -l 50000 - "$RANDOM"

		# Parse RIR datasets
		cd -- "${tmpdir_work}" || TMP_FILE_ERR
		PARSE_RIR_DATASETS

		# Add ASN info of blocked countries to ASN array
		for _asn in $(find "${tmpdir_asn}" -type f -exec cat -- {} + | mysort -u); do
			_asn_array[_a_counter++]="${_asn}"
		done

		# Nuke local temp files
		rm -rf -- "${tmpdir_asn}" "${tmpdir_rir}" || TMP_FILE_DEL_ERR
		unset tmpdir_asn tmpdir_rir
	fi

	# Mark _asn_array as read-only
	readonly _asn_array

	# Perform Tor filtering if enabled
	if ((_TOR_FILTER > 0)); then
		TOR_FILTER
	fi

	# Perform ASN to IP mapping via RADb whois service
	if ((${#_asn_array[@]} > 0)); then
		ASN_FETCH > "$(TMP_FILE)" || ERR 'Failed to perform ASN lookup via radb.net!'
	fi

	# Hail Mary Mitigation / SSH authlog analysis
	if ((_HAIL_MARY == 1)); then
		HAIL_MARY_MITIGATE > "${tmp_authlog}" || ERR 'Failed to extract authlog data!'
	fi

	# Run user-provided custom list fetch function if enabled
	if ((_USE_CONFIG == 1)); then
		tmpdir_custom="$(mktemp -d "${tmpdir_main}/custom.XXXXXX")" || TMP_FILE_ERR
		cd -- "${tmpdir_custom}"
		CUSTOM_LISTS || ERR 'User provided custom function returned non-zero exit status!'
		cd -- "${tmpdir_work}"
	fi

	# Generate blocklist
	LIST_GEN

	# If -x option is specified, we simply print the list to stdout.
	if ((_EXPORT == 1)); then
		if ((_EXPORT_RAW == 1)); then
			SANITIZE_ARRAY < "${tmp_finout}"
		else
			cat -- < "${tmp_finout}"
		fi
	else
		# Install Newly Generated Blocklist
		LIST_INSTALL
	fi

	# Print Blocklist Stats
	WARNING "${stats}"
}

# ZSH needs to run in compatibility mode to prevent it from puking
if command -v emulate >/dev/null ; then
	emulate ksh
	set -efo pipefail # ZSH annoyingly clears all our 'set' options...
fi

# Make sure shell supports typeset
command -v typeset >/dev/null || ERR 'Are you running a modern shell? Current shell does not appear to support the non-POSIX "typeset" command...'

# Execute main function
main "$@"