#!/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 "Medical Fascism" -- Released TBD 00, 2022

# In loving memory of Ron and Jan Sather

# Those who can make you believe absurdities can make you commit atrocities.

# Fetch, parse and generate domain blocklist data into format suitable
# for ingestion by RPZ compatible DNS servers and/or unbound/unwind.

readonly version='0.6-BETA-16'
readonly release_date='TBD'
readonly release_name='Medical Fascism'

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: unbound-adblock [-DVnx] [-C config-file] [-R retry-limit] [-P maxprocs]\n'
	printf '                       [-W output-file] [-f blocklist-file] [-l url-file]\n'
	printf '                       [-o long-option] [-r domain] [-u url] [-w whitelist-file]\n'
	printf '                       [file ...]\n'
	printf '       unbound-adblock -h|v\n'
	exit 1
}

OLD_CONF_RESET() {
	WARN_ERR "ERROR: Invalid data in file: '${_OUTFILE}'"
	cp -- "${tmp_oldconf}" "${_OUTFILE}" || ZERO_OUTPUT
	if ((_BLK_FMT == 3)); then
		unwind -n 2>&1 || ZERO_OUTPUT
	else
		nice unbound-checkconf || ZERO_OUTPUT
	fi >/dev/null
	ERR "Restoring backup '${_OUTFILE}'"
}

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

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 'unbound-adblock' -- "$*"
}

ZERO_OUTPUT() {
	WARN_ERR "Failed to restore backup '${_OUTFILE}', zeroing out file!"
	cp -- /dev/null "${_OUTFILE}" || WARN_ERR "ERROR: Failed to clear ${_OUTFILE}"
	exit 1
}

# ------------------------------------------------------------------------------
# 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 availble 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() {
true
}

# ------------------------------------------------------------------------------
# Input Validation and Sanitization
# ------------------------------------------------------------------------------

# POSIX ERE don't support look-aheads, so we do our length and character composition checks with awk in a secondary step
# Modified from regex found here: https://github.com/LinusU/domain-name-regex/blob/master/index.js
readonly hostname_regex='^(([[:alnum:]_][[:alnum:]_-]{0,61}[[:alnum:]_]|[[:alnum:]_])\.)+(([[:alnum:]][[:alnum:]\-]{0,61}[[:alnum:]]|[[:alnum:]]))$'
# PCRE with lookaheads: '^(?=.{0,253}$)(([[:alnum:]_][[:alnum:]_-]{0,61}[[:alnum:]_]|[[:alnum:]_])\.)+((?=.*[^0-9])([[:alnum:]][[:alnum:]\-]{0,61}[[:alnum:]]|[[:alnum:]]))$'

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

SANITIZE_ARRAY_SORTED() {
	SANITIZE_ARRAY  | mysort -u
}

# ------------------------------------------------------------------------------
# List Generation and Installation Functions
# ------------------------------------------------------------------------------

awk_list_gen='BEGIN{ cmd = "mktemp -q " LISTDIR "/tmp.XXXXXXX" ; if( ( cmd | getline result ) > 0 ) TMPFILE = result ; else exit 1 }
($1 == "0.0.0.0" || $1 == "127.0.0.1") && (length($2) <= 253 && length($2) >= 3) && $2 ~ /[[:alpha:]]/ {sub("\r", "", $2); print tolower($2) >> TMPFILE}
$1 ~ /[[:alpha:]]/ && (length($1) <= 253 && length($1) >= 3) {sub("\r", "", $1); print tolower($1) >> TMPFILE}'

LIST_GEN() {
	# Generate local temp files
	typeset tmpdir_export tmp_raw_domains
	tmpdir_export="$(mktemp -d "${tmpdir_main}/export.XXXXXX")" || TMP_FILE_ERR
	tmp_raw_domains="$(TMP_FILE_SCRATCH)"

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

	# Process blocklists
	find -L "${tmpdir_fetch}" -type f -print0 | xargs -0n 1 -P "${_PARALLEL}" -- "${awk_pref}" -v LISTDIR="${tmpdir_export}" "${awk_list_gen}" || ERR 'Failed to process one or more blocklists!'

	# Parse, sort and merge lists
	case "${grep_pref}" in
	    rg)    nice -- rg -Ix --no-ignore --no-ignore-files -- "${hostname_regex}" "${tmpdir_export}" || true ;;
	    ugrep) nice -- ugrep -ERh -- "${hostname_regex}" "${tmpdir_export}" || true ;;
	    *)     find "${tmpdir_export}" -type f -print0 | xargs -0n 1 -P "${_PARALLEL}" -- nice -- "${grep_pref}" -Ehx --line-buffered -- "${hostname_regex}" || true ;;
	esac | mysort -u > "${tmp_raw_domains}" || TMP_FILE_ERR

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

	# Convert raw domain data to necessary output format and date + stats header to generated blocklist
	if ((_BLK_FMT == 1)); then
		# Cross-platform RPZ data
		printf '%s\n' "${stats}" | sed 's/^/;; /g'
		if ((${#_whitelist[@]} > 0)); then
			printf '\n;; Whitelist:\n\n'
			for _i in "${_whitelist[@]}"; do
				printf '%s CNAME rpz-passthru.\n' "${_i}"
			done | mysort -u
		fi
		if [[ -s ${tmp_raw_domains} ]]; then
			printf '\n;; Blocklist:\n\n'
			myawk -v RC="${_DNS_RESPONSE}" '{ print $1, RC }' < "${tmp_raw_domains}"
		fi
	elif ((_BLK_FMT == 2)); then
		# Old unbound specific 'local-data' format
		printf '%s\n' "${stats}" | sed 's/^/# /g'
		if [[ -s ${tmp_raw_domains} ]]; then
			printf '\n# Blocklist:\n\n'
			myawk -v RC="${_DNS_RESPONSE}" 'BEGIN { OFS = "" } { print "local-zone: \"", $1, "\" " RC}' < "${tmp_raw_domains}"
		fi
		if ((${#_whitelist[@]} > 0)); then
			printf '\n# Whitelist:\n\n'
			for _i in "${_whitelist[@]}"; do
				printf 'local-zone: "%s" always_transparent\n' "${_i}"
			done | mysort -u
		fi
	elif ((_BLK_FMT == 3)); then
		# Unwind format (raw domains)
		printf '%s\n' "${stats}" | sed 's/^/# /g'
		if [[ -s "${tmp_raw_domains}" ]]; then
			printf '\n# Blocklist:\n\n'
			cat -- < "${tmp_raw_domains}"
		fi
	fi | USER_FILTERING > "${tmp_finout}"

	# Delete local temp files
	rm -rf -- "${tmpdir_export}" "${tmp_raw_domains}" || TMP_FILE_DEL_ERR
}

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

	# Reload unbound list only if there are changes
	# Note: '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}" || TMP_FILE_ERR
			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

	# Backup old blocklist and move newly generated blocklist into place
	cp -- "${_OUTFILE}" "${tmp_oldconf}" || TMP_FILE_ERR
	cp -- "${tmp_finout}" "${_OUTFILE}" || ERR "Failed to update '${_OUTFILE}'"

	# Ensure proposed changes are valid before reloading unbound/unwind
	if ((_BLK_FMT == 1)); then
		nice unbound-checkconf || OLD_CONF_RESET
		"${getroot}" -- "${unbound_control}" -q auth_zone_reload unbound-adblock || OLD_CONF_RESET
		"${getroot}" -- "${unbound_control}" -q flush_zone unbound-adblock || OLD_CONF_RESET
	elif ((_BLK_FMT == 2)); then
		nice unbound-checkconf || OLD_CONF_RESET
		"${getroot}" -- "${unbound_control}" -q reload || OLD_CONF_RESET
	elif ((_BLK_FMT == 3)); then
		unwind -n 2>&1 || OLD_CONF_RESET
		"${getroot}" -- "${unwindctl}" reload 2>&1 || OLD_CONF_RESET
	fi >/dev/null

	# Run logging function
	if ((_LOG == 1)); then
		LOG_ROTATE
	fi
	# Delete local temp files
	rm -f -- "${tmp_oldconf}" || TMP_FILE_DEL_ERR
}

# ------------------------------------------------------------------------------
# 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 rotate 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
}

PRINT_STATS() {
	# Calculate blocklist entries in current and previously installed lists
	typeset blk_num changes new_num old_num
	[[ -f ${_OUTFILE} ]] || typeset _OUTFILE='/dev/null'
	blk_num="$(SANITIZE_ARRAY < "${tmp_raw_domains}" | wc -l)"
	new_num="$((${#_whitelist[@]} + blk_num))"
	old_num="$(SANITIZE_ARRAY < "${_OUTFILE}" | wc -l)"
	changes="$((new_num - old_num))"
	((changes >= 0)) && changes="+${changes}"
	# Print stats nicely formatted.
	printf 'Date Created: %s\n' "$(date)"
	printf '\nChanges (+/-): %s\n' "${changes}"
	printf 'Domain total :  %d\n\n' "${new_num}"
}

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

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_BOOL() {
	case "$*" in
	    0|1) return 0 ;;
	    *) return 1 ;;
	esac
}

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

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

	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

	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
	
	printf '# Version Info: unbound-adblock 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}"

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

PRE_EXEC_TESTS() {
	((_CHECK_UID == 1)) && CHECK_PRIVILEGE
	# Make sure requisite utilities are installed
	CHECK_CMD 'find' 'mktemp' 'sed' 'split' 'xargs'
	if ((_EXPORT == 0)); then
		# Ensure relevant files/dirs exist
		CHECK_DISK
		# Make sure unbound/unwind is running and that conditions are sane
		if ((_BLK_FMT == 3)); then
			"${unwindctl}" status 2>&1 || ERR 'unwind does not appear to be running!'
			unwind -n 2>&1 || ERR 'unwind: Invalid configuration!'
		else
			# Make sure unbound is up and reachable with 'unbound-control'
			"${getroot}" -- "${unbound_control}" -q status || ERR 'Unable to connect to unbound with "unbound-control"'
			nice unbound-checkconf || ERR 'unbound: Invalid configuration!'
		fi
	fi
}

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

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

	# Internal boolean checks
	for _i in "${_CHECK_UID}" "${_CUSTOM_FILTER}" "${_DRY_RUN}" "${_EXPORT}" "${_EXPORT_RAW}" \
		  "${_LOG}" "${_STRICT}" "${_USE_CONFIG}" "${_VERBOSE}" ; do
		IS_BOOL "${_i}" || ERR "Internal accounting error: bool_check: ${bool_cnt}"
		((++bool_cnt))
	done

	# Perform unbound/unwind path related sanity checks
	if ((_EXPORT == 0 || _DEBUG == 1)); then
		# Make sure necessary utilities are available based on specified backend
		if ((_BLK_FMT == 3)); then
			CHECK_CMD 'unwind' 'unwindctl'
			unwindctl="$(command -v unwindctl)"
		else
			CHECK_CMD 'unbound-checkconf' 'unbound-control'
			unbound_control="$(command -v unbound-control)"
		fi
		# Check for file/symlink at '/var/unbound-adblock/block.db'
		if [[ -z ${_OUTFILE} && -f ${_SYMLINK} ]]; then
			CHECK_W_PERM "${_SYMLINK}"
			_OUTFILE="${_SYMLINK}"
		fi
		# Set output/path related variables based on specified operating system
		case "${_OS_TYPE}" in
		    dragonfly|freebsd)
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 1 ]] && _OUTFILE='/usr/local/etc/unbound/adblock.rpz'
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 2 ]] && _OUTFILE='/usr/local/etc/unbound/adblock.conf' ;;
		    netbsd)
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 1 ]] && _OUTFILE='/usr/pkg/etc/unbound/adblock.rpz'
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 2 ]] && _OUTFILE='/etc/unbound/adblock.conf' ;;
		    openbsd)
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 1 ]] && _OUTFILE='/var/unbound/db/adblock.rpz'
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 2 ]] && _OUTFILE='/var/unbound/db/adblock.conf'
			[[ -z ${_OUTFILE} && ${_BLK_FMT} -eq 3 ]] && _OUTFILE='/var/db/unwind-adblock.db' ;;
		    *)	# Custom OS must specify output with '-W' or create symlink in expected location.
			[[ -n ${_OUTFILE} ]] || ERR 'Unable to determine output file. Please specify manually with the "-W" option.'
		esac
	fi

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

	# '-r' and '-w' whitelisting options are only supported with RPZ and local-data backends
	if ((${#_whitelist[@]} > 0 && _BLK_FMT == 3)); then
		ERR '"-r" and "-w" flags are not supported by unwind backend!'
	fi

	# Ensure specified DNS response format is sane
	if ((_BLK_FMT == 1)); then
		# Cross-platform RPZ data
		case "$_DNS_CODE" in
		    1) _DNS_RESPONSE='CNAME .' ;;  # NXDOMAIN
		    2) _DNS_RESPONSE='CNAME *.' ;; # NODATA
		    3) ERR 'RPZ does not support response code "REFUSED".' ;;
		    4) ERR 'RPZ does not support response code "NULL".' ;;
		    *) ERR "Invalid DNS return code value for RPZ backend: $_DNS_CODE" ;;
		esac
	elif ((_BLK_FMT == 2)); then
		# Old unbound specific 'local-data' format
		case "$_DNS_CODE" in
		    1) _DNS_RESPONSE='always_nxdomain' ;;
		    2) _DNS_RESPONSE='always_nodata' ;; # Requires unbound >= 1.13.1
		    3) _DNS_RESPONSE='always_refuse' ;;
		    4) _DNS_RESPONSE='always_null' ;; # Requires unbound >= 1.13.1
		    *) ERR "Invalid DNS return code value for local-data backend: $_DNS_CODE" ;;
		esac
	fi

	# Set doas/sudo path
	if [[ -z ${getroot} ]]; then
		if ((_EXPORT == 0 || _DEBUG == 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 [[ -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
}

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


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

# 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
	#_OUTFILE=''
	readonly _LOGDIR='/var/log/unbound-adblock'
	readonly _LOGFILE="${_LOGDIR}/unbound-adblock.log"
	readonly _LOGGZIP="${_LOGDIR}/unbound-adblock.log.0.gz"
	readonly _SYMLINK='/var/unbound-adblock/block.db'
	readonly _USER='_adblock'

	# 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}"

	### Variable declarations:
	# Initialize index / counters
	typeset _f_counter=0 _l_counter=0 _w_counter=0
	# Initialize case (in)sensitive vars
	typeset -l _opt_arg
	# Initialize local iterators
	typeset _abs_path _i

	# 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'
	_BLK_FMT=1 # 1=RPZ, 2=local-data, 3=unwind
	_CHECK_UID=1 ; _CUSTOM_FILTER=0 ; _DNS_CODE=1 ; _DRY_RUN=0 ; _EXPORT=0 ; _EXPORT_RAW=0
	_LOG=1 _PARALLEL=3 ; _RETRY=5 ; _STRICT=1 ; _USE_CONFIG=0 ; _VERBOSE=1

	# Command-line option handling
	while getopts :C:DP:R:U:VW:f:hl:no:r:u:vw:x _opts ; do
	case "${_opts}" in
	    C)  # Load config file
		CHECK_R_PERM "${OPTARG}"
		# shellcheck source=/dev/null
		. "${OPTARG}"
		_USE_CONFIG=1 ;;
	    D)  # Disable UID checking
		_CHECK_UID=0 ;;
	    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}" ;;
	    U)  # Set URL fetch utility user agent
		_AGENT="${OPTARG}" ;;
	    V)  # Disable printing of warning messages
		_VERBOSE=0 ;;
	    W)  # Set blocklist output path (for unbound/unwind backends)
		CHECK_W_PERM "${OPTARG}"
		_OUTFILE="${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}" ;;
	    h)  # Print help message and exit
		HELP_MESSAGE ;;
	    l)  # Add 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 ;;
	    n)  # Sanity check -- no execution
		_DRY_RUN=1 ;;
	    o)  # Long form runtime options
		_opt_arg="${OPTARG}"
		case "${_opt_arg}" in
		    # Debugging options
		    print-debug) 
			_CHECK_UID=0 ; _DEBUG=1 ; _DRY_RUN=1
			_EXPORT=1 ; _LOG=0 ;;
		    # Configuration options
		    custom-filter) _CUSTOM_FILTER=1 ;;
		    no-custom-filter) _CUSTOM_FILTER=0 ;;
		    log) _LOG=1 ;;
		    no-log) _LOG=0 ;;
		    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}" ;;
		    # Output/Export Formatting Options
		    domain)
			_BLK_FMT=3 ; _CHECK_UID=0 
			_EXPORT=1 ; _LOG=0 ;;
		    export-raw)
			_EXPORT=1 ; _EXPORT_RAW=1 ; _CHECK_UID=0 
			_LOG=0 ; _VERBOSE=0 ;;
		    # Blocklist Formats and Response Codes
		    rpz)     _BLK_FMT=1 ;;
		    unbound) _BLK_FMT=2 ;;
		    unwind)  _BLK_FMT=3 ;;
		    dns-nxdomain) _DNS_CODE=1 ;;
		    dns-nodata)   _DNS_CODE=2 ;;
		    dns-refused)  _DNS_CODE=3 ;;
		    dns-null)     _DNS_CODE=4 ;;
		    *) ERR "-o: Invalid argument: '${OPTARG}'" ;;
		esac
		;;
	    r)  # Add custom whitelisting rule
		_whitelist[_w_counter++]="${OPTARG}" ;;
	    u)  # Add blocklist URL
		_blocklist_url[_l_counter++]="${OPTARG}" ;;
	    v)  # Print version and exit
		printf 'unbound-adblock %s (%s) Released on: %s\n' "${version}" "${release_name}" "${release_date}"
		exit 0 ;;
	    w)  # Add custom whitelisting rules in bulk from local list
		CHECK_R_PERM "${OPTARG}"
		for _i in $(SANITIZE_ARRAY_SORTED < "${OPTARG}"); do
			_whitelist[_w_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 _CHECK_UID _CUSTOM_FILTER _DEBUG _DNS_CODE _DRY_RUN _EXPORT \
		 _EXPORT_RAW _LOG _STRICT _USE_CONFIG _VERBOSE
	# Mark array data as read-only
	readonly _blocklist_url _file_array _whitelist

	# 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 _DNS_RESPONSE _PARALLEL _OUTFILE _RETRY awk_pref grep_pref getroot \
		 netget sort_pref unbound_control unwindctl

	# Config test / dry run
	if ((_DRY_RUN == 1)); then
		((_DEBUG == 1)) && PRINT_DEBUG
		printf '\n# Configuration: ok\n'
		exit 0
	fi

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

	# Safely create global temporary directories
	tmpdir_main="$(mktemp -d)" || 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_finout="$(TMP_FILE_SCRATCH)"

	# Mark global temporary directories and file locations as read-only
	readonly tmp_finout tmpdir_main tmpdir_fetch tmpdir_scratch tmpdir_work

	# Set working directory
	cd -- "${tmpdir_work}" || TMP_FILE_ERR

	# Create symlinks 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

	# 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 data
	LIST_GEN

	# If the -x option is specified, we only export the generated list to stdout
	# without reloading the DNS server or touching the filesytem (other than /tmp)
	if ((_EXPORT == 1)); then
		if ((_EXPORT_RAW == 1)); then
			mygrep '^[[:alnum:]_\-]' < "${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 "$@"



