#!/bin/sh
#
###################################################################
# Postallow - Automatic Postcreen Whitelist / Blacklist Generator #
# https://github.com/edmundlod/postallow                          #
# Originally by Steve Jenkins (https://www.stevejenkins.com/)     #
# Renamed and updated in 2025 by Edmund Lodewijks                 #
###################################################################

version="4.5.1"
lastupdated="2026-05-27"

# Usage: 1) Place entire /postallow directory in /usr/local/scripts
#	 2) Move (and modify, if needed) postallow.conf to /etc
#	 3) Run /usr/local/scripts/postallow
# Optional config file passed via command line overrides the default config file location.
#
# Requires SPF-Tools (https://github.com/edmundlod/spf-tools)
# Please update your copy of spf-tools whenever you update Postallow
#
# Thanks to `nabbi` for Perl CIDR integration.
# Thanks to Steve Jenkins (https://www.stevejenkins.com/) for the orignal version
#           (called Postwhite).
# Thanks to Mike Miller (mmiller@mgm51.com) for gallowlist.sh script.
# Thanks to Jan Sarenik for SPF-Tools.
# Thanks to Jose Borges Ferreira for IPv4 normalization help.
# Thanks to Ricardo Iván Vieitez Parra for improved error reporting, normalization, conf file
#           improvements, and removal of bash-isms so that script is usable on more systems.
# Thanks to Steve Cook for Yahoo! IP scraping help.
# Thanks to all the additional contributors on GitHub!
#
# USER-DEFINABLE OPTIONS AND CUSTOM HOSTS STORED IN /etc/postallow.conf
# CONFIGURATION FILE CAN ALSO BE PASSED FROM COMMAND LINE
#
# NO NEED TO EDIT PAST THIS LINE
#
#################################################################

permit_line_v4="%s\tpermit\n"
reject_line_v4="%s\treject\n"

permit_line_v6="${permit_line_v4}"
reject_line_v6="${reject_line_v4}"

tmpBase=$(basename "$0")

# Abort script on error (FYI: enabling will cause script to exit silently if mailer has no valid results)
set -e

if ! aggregateCIDR=$(command -v aggregateCIDR.pl) ; then
    echo "fatal: unable to locate aggregateCIDR.pl"
    echo "https://github.com/edmundlod/route-summarization"
    exit 1
fi

printf "Starting Postallow v$version ($lastupdated)\n"

# Parse command-line options
quick_add_domain=""
while [ $# -gt 0 ]; do
    case "$1" in
        -q|--quick-add)
            shift
            if [ $# -eq 0 ]; then
                printf "%s: --quick-add requires a domain argument\n" "$0" >&2
                exit 1
            fi
            quick_add_domain="$1"
            shift
            ;;
        -*)
            printf "%s: unknown option: %s\n" "$0" "$1" >&2
            exit 1
            ;;
        *)
            config_file="$1"
            shift
            ;;
    esac
done

# Locate config file if not set by the positional argument above
if [ -z "${config_file:-}" ]; then
    if [ -f "/etc/postallow.conf" ]; then
        config_file="/etc/postallow.conf"
    elif [ -f "/etc/postallow/postallow.conf" ]; then
        config_file="/etc/postallow/postallow.conf"
    elif [ -f "/usr/local/etc/postallow.conf" ]; then
        config_file="/usr/local/etc/postallow.conf"
    elif [ -f "/usr/local/etc/postallow/postallow.conf" ]; then
        config_file="/usr/local/etc/postallow/postallow.conf"
    fi
fi

# Read config file options
if [ ! -s "$config_file" ] ; then
	printf "%s: Can't find %s. Exiting.\n" "$0" "$config_file" 1>&2
	exit 1
fi
printf "\nReading options from %s...\n" "$config_file"
. "${config_file}"

# Defaults for settings removed from conf in v4.2, and backward compat rename
output_dir=${output_dir:-${postfixpath:-/var/lib/postallow}}
invalid_cidr=${invalid_cidr:-fix}
allowlist=${allowlist:-postscreen_spf_allowlist.cidr}
blocklist=${blocklist:-postscreen_spf_blocklist.cidr}
if [ -z "${yahoo_static_hosts:-}" ]; then
	for _d in /usr/share/postallow /usr/local/share/postallow; do
		if [ -f "$_d/yahoo_static_hosts.txt" ]; then
			yahoo_static_hosts="$_d/yahoo_static_hosts.txt"
			break
		fi
	done
fi

# Format an already-normalized ip4:/ip6: entry for the output file.
# Invalid CIDRs are handled upstream by normalize.sh before aggregation.
format_ip() {
	format_ip_iptype="$( echo "$1" | cut -d\: -f1 )"
	format_ip_ip="$( echo "$1" | cut -d\: -f2- )"
	if [ -n "$format_ip_ip" ]; then
		if [ x"$format_ip_iptype" = x"ip4" ]; then
			printf "$(eval printf "%s" "\${${2}_line_v4}")" "$format_ip_ip"
		elif [ x"$format_ip_iptype" = x"ip6" ] ; then
			printf "$(eval printf "%s" "\${${2}_line_v6}")" "$format_ip_ip"
		fi
	fi
}

# Resolve _norm_flag early (needed by both --quick-add and the main run)
case "${invalid_cidr}" in
    remove) _norm_flag="-i" ;;
    *)      _norm_flag="" ;;
esac

# ---------------------------------------------------------------------------
# --quick-add: resolve a single domain and append its IPs to the allowlist,
# then reload Postfix.  The change is temporary — the next full postallow run
# will regenerate the file.
# ---------------------------------------------------------------------------
if [ -n "${quick_add_domain}" ]; then
    allowlist_file="${output_dir}/${allowlist}"

    printf "\nQuick-adding '%s'...\n" "${quick_add_domain}"

    # Create three temp files for this lightweight run
    qa_t1="$(mktemp -q /tmp/"${tmpBase}".XXXXXX)" || \
        { printf "%s: Can't create temp files, exiting\n" "$0" >&2; exit 1; }
    qa_t2="$(mktemp -q /tmp/"${tmpBase}".XXXXXX)" || \
        { rm -f "${qa_t1}"; printf "%s: Can't create temp files, exiting\n" "$0" >&2; exit 1; }
    qa_t3="$(mktemp -q /tmp/"${tmpBase}".XXXXXX)" || \
        { rm -f "${qa_t1}" "${qa_t2}"; printf "%s: Can't create temp files, exiting\n" "$0" >&2; exit 1; }

    qa_cleanup() { rm -f "${qa_t1}" "${qa_t2}" "${qa_t3}"; }

    printf "Querying SPF records for '%s'...\n" "${quick_add_domain}"
    "${spftoolspath}"/despf.sh "${quick_add_domain}" | (grep -Ei '^ip' || true) > "${qa_t1}"

    if [ ! -s "${qa_t1}" ]; then
        printf "No IP entries found in SPF records for '%s'.\n" "${quick_add_domain}" >&2
        printf "Is this a valid mail-sending domain with an SPF record?\n" >&2
        qa_cleanup
        exit 1
    fi

    printf "Aggregating CIDRs...\n"
    sed '/\./s/\/32//g' "${qa_t1}" | \
        "${spftoolspath}"/normalize.sh ${_norm_flag} | \
        sort -u | ${aggregateCIDR} --quiet --spf > "${qa_t2}"

    printf "Formatting rules...\n"
    while read -r ip; do
        format_ip "$ip" "permit"
    done < "${qa_t2}" > "${qa_t3}"

    qa_numrules="$(cat "${qa_t3}" | wc -l)"

    if [ "${qa_numrules}" -eq 0 ]; then
        printf "No valid CIDR rules could be generated for '%s'.\n" "${quick_add_domain}" >&2
        qa_cleanup
        exit 1
    fi

    if [ ! -f "${allowlist_file}" ]; then
        printf "Warning: %s does not exist yet.\n" "${allowlist_file}" >&2
        printf "Run postallow first to generate the base allowlist, then use --quick-add.\n" >&2
    fi

    {
        printf "# quick-add: %s — added %s (temporary; add to custom_hosts to make permanent)\n" \
            "${quick_add_domain}" "$(date)"
        cat "${qa_t3}"
    } >> "${allowlist_file}"

    qa_cleanup

    printf "\nAppended %d rule(s) for '%s' to:\n  %s\n" \
        "${qa_numrules}" "${quick_add_domain}" "${allowlist_file}"

    # Reload Postfix so the new rules take effect immediately
    if postfix reload 2>/dev/null; then
        printf "Postfix reloaded successfully.\n"
    else
        printf "\nCould not reload Postfix automatically (need root / sudo?).\n"
        printf "Apply the new rules manually:\n"
        printf "  postfix reload\n"
        printf "or:\n"
        printf "  systemctl reload postfix\n"
    fi

    printf "\n"
    printf "*** TEMPORARY CHANGE ***\n"
    printf "The next full postallow run will regenerate the allowlist and remove this entry.\n"
    printf "To keep '%s' permanently, edit your custom hosts file and add it to the\n" "${quick_add_domain}"
    printf "custom_hosts variable:\n\n"
    if [ -n "${custom_hosts_file:-}" ]; then
        printf "  %s\n\n" "${custom_hosts_file}"
    else
        printf "  Add '%s' to the custom_hosts variable in your custom hosts file.\n\n" \
            "${quick_add_domain}"
    fi

    exit 0
fi

# Read file with hosts that should be allowed
if [ ! -s "$allowlist_hosts" ] ; then
	printf "%s: Can't find %s. Exiting.\n" "$0" "$allowlist_hosts" 1>&2
	exit 1
fi
printf "\nReading options from %s...\n" "$allowlist_hosts"
. "${allowlist_hosts}"

# Source user custom hosts file
if [ -n "${custom_hosts_file:-}" ] && [ -f "$custom_hosts_file" ]; then
	printf "\nReading custom hosts from %s...\n" "$custom_hosts_file"
	. "${custom_hosts_file}"
fi

# Remove temp files
cleanup() {
	test -e "${tmp1}" && rm "${tmp1}"
	test -e "${tmp2}" && rm "${tmp2}"
	test -e "${tmp3}" && rm "${tmp3}"
	test -e "${tmp4}" && rm "${tmp4}"
	test -e "${tmp5}" && rm "${tmp5}"
	if [ x"$enable_blocklist" = x"yes" ] ; then
		test -e "${blktmp1}" && rm "${blktmp1}"
		test -e "${blktmp2}" && rm "${blktmp2}"
		test -e "${blktmp3}" && rm "${blktmp3}"
		test -e "${blktmp4}" && rm "${blktmp4}"
		test -e "${blktmp5}" && rm "${blktmp5}"
	fi
}

# Create temporary files
printf "\nCreating temporary files...\n"

tmpPrefix="tmp"
if [ x"$enable_blocklist" = x"yes" ]; then
	tmpPrefix="tmp blocktmp"
fi

for p in $tmpPrefix; do
	for i in 1 2 3 4 5; do
		t="$(mktemp -q /tmp/"${tmpBase}".XXXXXX)"
		if [ $? -ne 0 ]; then
			>&2 printf "%s: Can't create temp files, exiting...\n" "$0"
			cleanup
			exit 1
		fi
		eval ${p}${i}="$t"
	done
done


# Create host query function
query_host() {
	"${spftoolspath}"/despf.sh "$1" | (grep -Ei ^ip || true ) >> "${tmp1}"
}

query_block_host() {
	"${spftoolspath}"/despf.sh "$1" | (grep -Ei ^ip || true ) >> "${blocktmp1}"
}

# Create Yahoo query function that pulls their SPF records from their own Nameservers
query_yahoo_host() {
	"${spftoolspath}"/despf.sh -d ns1.yahoo.com "$1" | (grep -Ei ^ip || true ) >> "${tmp1}"
}

# Create progress dots function
show_dots() {
	while kill -0 "$1" 2>/dev/null; do
		printf "."
		sleep 1
	done
	printf "\n"
}

# Let's DO this!

printf "\nRecursively querying SPF records of selected allowlist mailers...\n"

printf "\nQuerying email hosts...\n"

for h in ${email_hosts}; do
	query_host "${h}"
done

printf "\nQuerying social network hosts...\n"

for h in ${social_hosts}; do
	query_host "${h}"
done

printf "\nQuerying ecommerce hosts...\n"

for h in ${commerce_hosts}; do
	query_host "${h}"
done

printf "\nQuerying bulk mail hosts...\n"

for h in ${bulk_hosts}; do
	query_host "${h}"
done

printf "\nQuerying miscellaneous hosts...\n"

for h in ${misc_hosts}; do
	query_host "${h}"
done

printf "\nQuerying custom hosts...\n"

for h in ${custom_hosts}; do
	query_host "${h}"
done

if [ x"$include_yahoo" = x"yes" ] ; then
	printf "\nIncluding scraped Yahoo! outbound hosts...\n"

	if [ ! -s "${yahoo_static_hosts}" ]; then
		>&2 printf "WARNING: %s is empty or missing. Run scrape_yahoo to update it.\n" "${yahoo_static_hosts}"
	else
		cat "${yahoo_static_hosts}" >> "${tmp1}"
	fi
fi

if [ x"$enable_blocklist" = x"yes" ] ; then
	printf "\nQuerying blocklist hosts...\n"

	for h in ${blocklist_hosts}; do
		query_block_host "${h}"
	done
fi

printf "\nCIDR and Host summarization...\n"
# normalize.sh fixes invalid IPv4 CIDRs (non-null host bits) before aggregation;
# aggregateCIDR.pl is from nabbi/route-summarization, packaged at edmundlod/route-summarization
sed '/\./s/\/32//g' "${tmp1}" | "${spftoolspath}"/normalize.sh ${_norm_flag} | sort -u | ${aggregateCIDR} --quiet --spf > "${tmp2}" &
show_dots "$!"

if [ x"$enable_blocklist" = x"yes" ] ; then
        cat "${blocktmp1}" | "${spftoolspath}"/normalize.sh ${_norm_flag} | sort -u | ${aggregateCIDR} --quiet --spf > "${blocktmp2}" &
        show_dots "$!"
fi

#Format the lists
printf "\nFormatting allowlist IPv4 CIDRs...\n"
cat "${tmp2}" | while read ip; do
format_ip "$ip" "permit"
done >> "${tmp3}" &
show_dots "$!"

if [ x"$enable_blocklist" = x"yes" ] ; then
	printf "\nFormatting blocklist IPv4 CIDRs...\n"
	cat "${blocktmp2}" | while read ip; do
		format_ip "$ip" "reject"
	done >> "${blocktmp3}" &
	show_dots "$!"
fi

# Sort, uniq, and count final rules
# Have to do sort and uniq separately, as 'sort -u -t. -k1,1n...' removes valid rules
printf "\nSorting allowlist rules...\n"
sort -t. -k1,1n -k2,2n -k3,3n -k4,4n "${tmp3}" > "${tmp4}"
uniq "${tmp4}" >> "${tmp5}"
numrules="$(cat "${tmp5}" | wc -l)"

if [ x"$enable_blocklist" = x"yes" ] ; then
	printf "\nSorting blocklist rules...\n"
	sort -t. -k1,1n -k2,2n -k3,3n -k4,4n "${blocktmp3}" > "${blocktmp4}"
	uniq "${blocktmp4}" >> "${blocktmp5}"
	numblockrules="$(cat "${blocktmp5}" | wc -l)"
fi

# Write allowlist and blocklist to Postfix directory
printf "\nWriting $numrules allowlist rules to ${output_dir}/${allowlist}...\n"
printf "# Whitelist generated by Postallow v$version on $(date)\n# https://github.com/edmundlod/postallow/\n# $numrules total rules\n" > "${output_dir}"/"${allowlist}"
cat "${tmp5}" >> "${output_dir}"/"${allowlist}"

if [ x"$enable_blocklist" = x"yes" ] ; then
	printf "\nWriting $numblockrules blocklist rules to ${output_dir}/${blocklist}...\n"
	printf "# Blacklist generated by Postallow v$version on $(date)\n# https://github.com/edmundlod/postallow/\n# $numblockrules total rules\n" > "${output_dir}"/"${blocklist}"
	cat "${blocktmp5}" >> "${output_dir}"/"${blocklist}"
fi

cleanup

printf '\nDone!\n'

exit
