#!/bin/bash # spf - Resolve and inspect SPF DNS records # # Usage: # spf [options] show Print the published SPF record (or just: spf ) # spf [options] find {|a:} Is an IP covered, or an a: present? (and how) # spf [options] has Is a token present in the expanded record? (and where) # spf [options] flatten Print all IPs authorized by an SPF record # spf [options] check Check SPF health (lookups, includes, etc.) # spf [options] tree Print SPF include tree # spf [options] ir Print the raw resolver IR (tab-delimited) # spf -h # # Options: # -s, --server DNS server to query (default: 8.8.8.8) # -d, --domain Anchor %{d} macros when input is a raw record # -t, --tcp Force TCP instead of UDP for dig queries # -q, --quiet Errors only (suppress warnings) # -v, --verbose Show the resolution trace # -h, --help Show help message (or per-verb: spf -h) _spf() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="spf" ;; esac # ANSI palette for diagnostics, computed once (no per-call _color subshell): # empty unless stderr is a TTY (or CLICOLOR_FORCE is set) and NO_COLOR is # unset, so pipes/redirects and the test harness stay plain by default while # CLICOLOR_FORCE=1 lets a capture keep color. NO_COLOR wins when both are set. # https://no-color.org/ local C_BRK="" # brackets: dim, so the punctuation recedes local C_INF="" # INF severity token: dim local C_WRN="" # WRN severity token: yellow local C_ERR="" # ERR severity token: red local C_NAME="" # script name: cyan local C_SRC="" # trace subject (the domain being resolved): green local C_RST="" if { [ -t 2 ] || [ -n "${CLICOLOR_FORCE:-}" ]; } && [ -z "${NO_COLOR:-}" ]; then C_BRK=$'\033[2m' C_INF=$'\033[2m' C_WRN=$'\033[33m' C_ERR=$'\033[31m' C_NAME=$'\033[36m' C_SRC=$'\033[32m' C_RST=$'\033[0m' fi # Severity prefixes assembled once. The cyan name and the ERR/WRN SEV tokens # stand out against the dim brackets; INF's token is itself dim (by design -- # routine -v trace recedes), so on those lines only the name carries color. # The message (default fg) follows the reset. # Each colored span is closed with C_RST before the next begins: SGR color and # intensity are orthogonal and cumulative, so re-emitting C_BRK (dim) does NOT # clear the prior span's color -- without the reset the SEV hue bleeds through # the middle ][ brackets and the name. Every C_RST here is load-bearing local PFX_ERR="${C_BRK}[${C_RST}${C_ERR}ERR${C_RST}${C_BRK}][${C_RST}${C_NAME}${SCRIPT_NAME}${C_RST}${C_BRK}]${C_RST} " local PFX_WRN="${C_BRK}[${C_RST}${C_WRN}WRN${C_RST}${C_BRK}][${C_RST}${C_NAME}${SCRIPT_NAME}${C_RST}${C_BRK}]${C_RST} " local PFX_INF="${C_BRK}[${C_RST}${C_INF}INF${C_RST}${C_BRK}][${C_RST}${C_NAME}${SCRIPT_NAME}${C_RST}${C_BRK}]${C_RST} " # Trailing C_RST closes any unclosed color a message carries, so an accent # that runs to end-of-line can't bleed onto the next line. Empty when color # is off, so plain output is unchanged _error() { printf '%s%s%s\n' "$PFX_ERR" "$*" "$C_RST" >&2; } _warn() { if [ "$VERBOSITY" -ge 1 ]; then printf '%s%s%s\n' "$PFX_WRN" "$*" "$C_RST" >&2; fi; } _info() { if [ "$VERBOSITY" -ge 2 ]; then printf '%s%s%s\n' "$PFX_INF" "$*" "$C_RST" >&2; fi; } # _show_help [verb] -- top-level menu when no (or unknown) verb; per-verb detail otherwise _show_help() { local verb="$1" local s; [ -t 1 ] && s=$'\033[4m' local r; [ -t 1 ] && r=$'\033[24m' case "$verb" in show) cat < directive is present SYNOPSIS $SCRIPT_NAME find ${s}domain${r} ${s}ip${r} $SCRIPT_NAME find ${s}domain${r} a:${s}host${r} DESCRIPTION With an IP: reports whether it is covered/listed by the record and how. With a:${s}host${r}: reports whether the directive is present literally (expected), only as a covering range or other directive (fragile), or absent. Resolves ${s}host${r} via dig. EXIT STATUS 0 IP covered, or a:host present literally 1 Runtime failure 2 Usage error 3 dig is not installed 4 Not found / absent 5 a:host present only as covered-by-range or via another directive (fragile) EOF ;; has) cat </dev/null | _normalize_txt } _spf_input() { local a="$1" case "$a" in "v=spf1"*) printf '%s\n' "$a" ;; # stdin: normalize like the domain path, so piping raw `dig +short TXT` # output (quoted, possibly multi-string) into any verb works the same # as a domain lookup. A clean record passes through untouched -) _normalize_txt ;; *) _dig_txt "$a" ;; esac } # Print one IR row _ir_row() { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6"; } # Print a host's A then AAAA addresses, one per line (empty if none) _resolve_host() { local host="$1" local tcp; [ "$USE_TCP" ] && tcp="+tcp" # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- $tcp expands to +tcp or empty; splitting dig short output on whitespace is intended dig @"$SERVER" $tcp +short A "$host" 2>/dev/null # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- $tcp expands to +tcp or empty; splitting dig short output on whitespace is intended dig @"$SERVER" $tcp +short AAAA "$host" 2>/dev/null } # _emit_host_ips _emit_host_ips() { local token="$1" local mech="$2" local src="$3" local depth="$4" local qual="$5" local host case "$token" in "$mech") host="$src" ;; # bare a / mx -> the current domain "$mech":*) host="${token#"$mech":}"; host="${host%%/*}" ;; "$mech"/*) host="$src" ;; # a/24 dual-cidr on current domain esac # cost row for the mechanism itself (1 lookup) _ir_row "$depth" "$src" "$qual" "$mech" "$host" 1 local ip # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- splitting dig short output on whitespace is intended for ip in $(_resolve_host "$host"); do case "$ip" in *:*) _ir_row "$depth" "$src" "$qual" ip6 "$ip" 0 ;; *) _ir_row "$depth" "$src" "$qual" ip4 "$ip" 0 ;; esac done } # _resolve_rec _resolve_rec() { local record="$1" local src="$2" local depth="$3" local ancestors="$4" _info "${C_SRC}${src}${C_RST}: $record" local token local qual local target # strip leading "v=spf1" by splitting the record on spaces (intentional word-split) # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- splitting an SPF record on whitespace is intended set -- $record shift # drop v=spf1 for token in "$@"; do # split qualifier qual="+" case "$token" in [+~?-]*) qual="${token%%[!+~?-]*}"; token="${token#?}" ;; esac case "$token" in ip4:*) _ir_row "$depth" "$src" "$qual" ip4 "${token#ip4:}" 0 ;; ip6:*) _ir_row "$depth" "$src" "$qual" ip6 "${token#ip6:}" 0 ;; all) _ir_row "$depth" "$src" "$qual" all "" 0 ;; include:*) target="${token#include:}" _ir_row "$depth" "$src" "$qual" include "$target" 1 _walk_into "$target" "$depth" "$ancestors" ;; redirect=*) target="${token#redirect=}" _ir_row "$depth" "$src" "$qual" redirect "$target" 1 _walk_into "$target" "$depth" "$ancestors" ;; a|a:*|a/*) _emit_host_ips "$token" a "$src" "$depth" "$qual" ;; mx|mx:*|mx/*) _emit_host_ips "$token" mx "$src" "$depth" "$qual" ;; exists:*) _ir_row "$depth" "$src" "$qual" exists "${token#exists:}" 1 ;; ptr) _ir_row "$depth" "$src" "$qual" ptr "" 1 ;; ptr:*) _ir_row "$depth" "$src" "$qual" ptr "${token#ptr:}" 1 ;; v=spf1) ;; # already shifted, but be safe *) ;; # unknown token: ignore (verify's job, not ours) esac done } # follow include/redirect with cycle + cap guards _walk_into() { local target="$1" local parent_depth="$2" local ancestors="$3" case " $ancestors " in *" $target "*) return 0 ;; esac # cycle: skip if [ "$parent_depth" -ge 20 ]; then return 0; fi # depth cap local sub; sub="$(_dig_txt "$target")" [ "$sub" ] || { _info "no SPF record for ${C_SRC}${target}${C_RST}" # void lookup: a queried name returned no SPF record. check counts these _ir_row "$((parent_depth + 1))" "$target" "+" void "$target" 0 return 0 } _resolve_rec "$sub" "$target" "$((parent_depth + 1))" "$ancestors $target" } _resolve() { local arg="$1" local anchor="${2:-$DOMAIN_ANCHOR}" local root; root="$(_spf_input "$arg")" if [ -z "$root" ]; then _error "No SPF record found for $arg" return 1 fi local rootsrc="$arg" case "$arg" in "v=spf1"*|-) rootsrc="${anchor:-}" ;; esac _resolve_rec "$root" "$rootsrc" 0 "$rootsrc" } # --- IPv4 and IPv6 CIDR helpers --- _ip4_to_int() { local IFS=. # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- intentional split of dotted-quad on '.' set -- $1 [ $# -eq 4 ] || return 2 local o for o in "$@"; do case "$o" in ''|*[!0-9]*) return 2 ;; esac [ "$o" -le 255 ] || return 2 done printf '%s\n' "$(( ($1 << 24) | ($2 << 16) | ($3 << 8) | $4 ))" } _ip4_in_cidr() { local ip="$1" local cidr="$2" local net="${cidr%/*}" local prefix case "$cidr" in */*) prefix="${cidr#*/}" ;; *) prefix=32 ;; esac case "$prefix" in ''|*[!0-9]*) return 2 ;; esac [ "$prefix" -le 32 ] || return 2 local ipi; ipi="$(_ip4_to_int "$ip")" || return 2 local neti; neti="$(_ip4_to_int "$net")" || return 2 local mask if [ "$prefix" -eq 0 ]; then mask=0 else mask=$(( (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF )) fi [ $(( ipi & mask )) -eq $(( neti & mask )) ] } _ip6_in_cidr() { local ip="$1" local cidr="$2" command -v python3 >/dev/null 2>&1 || return 2 python3 -c 'import ipaddress,sys try: sys.exit(0 if ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2], strict=False) else 4) except ValueError: sys.exit(2)' "$ip" "$cidr" } # _find_hit -- print a mechanism-appropriate match line _find_hit() { local ip="$1" local src="$2" local qual="$3" local mech="$4" local val="$5" local verb case "$mech" in ip4|ip6) # exact host (no prefix, or /32 v4, or /128 v6) -> "listed in"; a shorter prefix -> "covered by" case "$val" in */32|*/128) verb="is listed in" ;; */*) verb="is covered by" ;; *) verb="is listed in" ;; esac ;; a|mx) verb="matches" ;; exists) verb="matches" ;; *) verb="matches" ;; esac printf '%s %s %s:%s in %s (qualifier: %s)\n' "$ip" "$verb" "$mech" "$val" "$src" "$qual" } # Expand IP/domain-derivable macros. Returns 1 if an unsupported macro appears _expand_macro() { local s="$1" local qip="$2" local dom="$3" # shellcheck disable=SC1083 # "This { is literal." -- the { is intentional in the glob; case pattern matches literal %{s/l/o/h/p case "$s" in *%{[slohp]*) return 1 ;; esac # sender/HELO/ptr macros: unsupported local i_exp local ir_exp local v_exp case "$qip" in *:*) i_exp="$qip"; v_exp="ip6"; ir_exp="$qip" ;; # v6 nibble form omitted; v4 is the common case *) i_exp="$qip"; v_exp="in-addr" local IFS=. # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- splitting a dotted-quad for the %{ir} reverse transform set -- $qip; ir_exp="$4.$3.$2.$1" ;; esac s="${s//%{ir\}/$ir_exp}" s="${s//%{i\}/$i_exp}" s="${s//%{d\}/$dom}" s="${s//%{v\}/$v_exp}" s="${s//%%/%}" s="${s//%_/ }" s="${s//%-/%20}" printf '%s\n' "$s" } # --- verb implementations and their helpers --- # _resolve_one -- first A (fam 4) or AAAA (fam 6) address of host, or empty _resolve_one() { local host="$1" local fam="$2" local qtype; [ "$fam" = 6 ] && qtype=AAAA || qtype=A local tcp; [ "$USE_TCP" ] && tcp="+tcp" # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- $tcp expands to +tcp or empty; the split is intentional dig @"$SERVER" $tcp +short "$qtype" "$host" 2>/dev/null | head -n1 } # _find_a_host -- evaluate an a: query. # exit 0 literal present / 5 fragile (covered by range or other directive) / 4 absent _find_a_host() { local ir="$1" local host="$2" local input="$3" local rc local warned_py="" # (a) literal a: row present? local lit; lit="$(printf '%s\n' "$ir" | awk -F'\t' -v h="$host" '$4=="a" && $5==h{print; exit}')" if [ -n "$lit" ]; then local src; src="$(printf '%s\n' "$lit" | cut -f2)" local qual; qual="$(printf '%s\n' "$lit" | cut -f3)" printf '%s matches a:%s in %s (qualifier: %s)\n' "$host" "$host" "$src" "$qual" # dangling check: literal present but host resolves to nothing if [ -z "$(_resolve_host "$host")" ]; then _warn "a:$host is present but the host resolves to no addresses (dead directive)" fi return 0 fi # resolve the host for coverage checks (b/c) local addrs; addrs="$(_resolve_host "$host")" if [ -n "$addrs" ]; then local addr # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- splitting the newline-separated address list is intended for addr in $addrs; do local fam; case "$addr" in *:*) fam=6 ;; *) fam=4 ;; esac # walk IR rows; first covering ip4/ip6 (b) or a/mx-for-other-host (c) wins local depth local src local qual local mech local val local cost local _row # IFS-whitespace (TAB) merges consecutive separators, collapsing an empty # value field. Peel-split each row so empty fields are preserved. # shellcheck disable=SC2034 # "cost appears unused. Verify use (or export if used externally)." -- cost is read from the IR to keep field alignment but not used in this consumer while IFS= read -r _row; do depth="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" src="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" qual="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" mech="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" val="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" cost="${_row}" case "$mech" in ip4) [ "$fam" = 4 ] || continue if _ip4_in_cidr "$addr" "$val"; then printf '%s is NOT present literally, but its address %s is covered by ip4:%s in %s -- fragile: a flattened range does not self-heal if the host renumbers\n' "a:$host" "$addr" "$val" "$src" return 5 fi ;; ip6) [ "$fam" = 6 ] || continue _ip6_in_cidr "$addr" "$val" rc=$? if [ "$rc" -eq 0 ]; then printf '%s is NOT present literally, but its address %s is covered by ip6:%s in %s -- fragile: a flattened range does not self-heal if the host renumbers\n' "a:$host" "$addr" "$val" "$src" return 5 elif [ "$rc" -eq 2 ]; then [ "$warned_py" ] || { _warn "python3 not found; IPv6 matching is literal-only"; warned_py=1; } fi ;; a|mx) # case (c): IP shared with a different directive's host if [ "$val" != "$host" ] && [ "$addr" = "$(_resolve_one "$val" "$fam")" ]; then printf '%s is NOT present literally; its address %s is covered by %s:%s in %s, not your directive\n' "a:$host" "$addr" "$mech" "$val" "$src" return 5 fi ;; esac done </dev/null)" # Reassemble: join multi-string boundaries, strip quotes, keep v=spf1 # records (same transform _dig_txt applies; inlined here so the quoted # raw form survives for the -v character-string count below) local records; records="$(printf '%s\n' "$raw" | sed 's/" "//g; s/"//g' | grep -i '^v=spf1')" if [ -z "$records" ]; then _error "No SPF record found for $input" return 1 fi # Multiple published v=spf1 records are an RFC 7208 permerror. Show the # first and flag the rest, rather than merging them into one mangled line local n; n="$(printf '%s\n' "$records" | grep -c .)" [ "$n" -gt 1 ] && _warn "multiple published SPF records (RFC 7208 permerror); showing the first" printf '%s\n' "$records" | head -n1 # -v: note when the record was published as multiple character-strings (a # string caps at 255 bytes, so long records are split). The reassembled # form hides this; count quotes on the raw quoted line to recover it if [ "$VERBOSITY" -ge 2 ]; then local rawline; rawline="$(printf '%s\n' "$raw" | grep -i '^"v=spf1' | head -n1)" local nstr; nstr=$(( $(printf '%s' "$rawline" | tr -dc '"' | wc -c) / 2 )) # if (not &&): a false test as the function's last statement would # leak exit status 1 even though the record printed fine if [ "$nstr" -gt 1 ]; then _info "published as $nstr character-strings"; fi fi } _cmd_find() { local input="" local ip="" local arg for arg in "$@"; do case "$arg" in -*) ;; # global opts already consumed; ignore strays *) if [ -z "$input" ]; then input="$arg"; else ip="$arg"; fi ;; esac done if [ -z "$input" ]; then _error "Must provide a domain or record. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi if [ -z "$ip" ]; then _error "ip or a: is required. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi # Reject ambiguous ip4:/ip6: query inputs (spec 4.4) case "$ip" in ip4:*|ip6:*) _error "find does not take ip4:/ip6: queries (ambiguous). Use a bare IP for membership, \`has\` for token presence, or \`ir | grep\` for a literal scan. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; esac local ir; ir="$(_resolve "$input")" || return 1 # a: query -> presence/coverage evaluation (spec 4.2-4.3) case "$ip" in a:*) _find_a_host "$ir" "${ip#a:}" "$input"; return $? ;; esac local family; case "$ip" in *:*) family=6 ;; *) family=4 ;; esac local warned_py="" # Walk rows in order; first containing CIDR wins local depth local src local qual local mech local val local cost local rc local _row # IFS-whitespace (TAB) merges consecutive separators, collapsing an empty # value field. Peel-split each row so empty fields are preserved. # shellcheck disable=SC2034 # "cost appears unused. Verify use (or export if used externally)." -- cost is read from the IR to keep field alignment but not used in this consumer while IFS= read -r _row; do depth="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" src="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" qual="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" mech="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" val="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" cost="${_row}" case "$mech" in ip4) [ "$family" = 4 ] || continue if _ip4_in_cidr "$ip" "$val"; then _find_hit "$ip" "$src" "$qual" "$mech" "$val"; return 0; fi ;; ip6) [ "$family" = 6 ] || continue _ip6_in_cidr "$ip" "$val"; rc=$? if [ "$rc" -eq 0 ]; then _find_hit "$ip" "$src" "$qual" "$mech" "$val"; return 0 elif [ "$rc" -eq 2 ]; then [ "$warned_py" ] || { _warn "python3 not found; IPv6 matching is literal-only"; warned_py=1; } [ "$ip" = "$val" ] && { _find_hit "$ip" "$src" "$qual" "$mech" "$val"; return 0; } fi ;; exists) local name; if name="$(_expand_macro "$val" "$ip" "$src")"; then local tcp; [ "$USE_TCP" ] && tcp="+tcp" # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- optional +tcp flag; split is intentional if [ -n "$(dig @"$SERVER" $tcp +short A "$name" 2>/dev/null)" ]; then _find_hit "$ip" "$src" "$qual" exists "$val"; return 0 fi else _warn "cannot statically evaluate exists ($val)" fi ;; esac done <1 published-record lint is not applicable if [ "$raw" ]; then printf 'Record count: N/A (raw-record input)\n' else # Re-query TXT ourselves (the resolver's count is lost in a subshell) and # count v=spf1 records. 0 records can't reach here: _resolve already # failed above. >1 is a permerror per RFC 7208 local tcp; [ "$USE_TCP" ] && tcp="+tcp" local rec_count # shellcheck disable=SC2086 # "Double quote to prevent globbing and word splitting." -- $tcp expands to +tcp or empty; the split is intentional rec_count="$(dig @"$SERVER" $tcp +short TXT "$input" 2>/dev/null | sed 's/" "//g; s/"//g' | grep -ic '^v=spf1')" printf 'Published SPF records: %s\n' "$rec_count" if [ "$rec_count" -gt 1 ]; then printf ' PROBLEM: multiple published SPF records (RFC 7208 permerror)\n'; problems=1 fi fi if [ "$problems" -eq 0 ]; then printf 'OK: no problems found\n'; return 0 fi return 4 } _cmd_tree() { local input="$1" if [ -z "$input" ]; then _error "Must provide a domain or record. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local ir; ir="$(_resolve "$input")" || return 1 local depth local src local qual local mech local val local cost local indent local label local _row # IFS-whitespace (TAB) merges consecutive separators, collapsing an empty # value field. Peel-split each row so empty fields are preserved. # shellcheck disable=SC2034 # "cost appears unused. Verify use (or export if used externally)." -- cost is read from the IR to keep field alignment but not used in this consumer while IFS= read -r _row; do depth="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" src="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" qual="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" mech="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" val="${_row%%$'\t'*}"; _row="${_row#*$'\t'}" cost="${_row}" [ "$mech" = void ] && continue indent="$(printf '%*s' "$(( depth * 4 + 2 ))" '')" case "$mech" in all) label="${qual}all" ;; *) label="$mech${val:+:$val}" ;; esac printf '%s%s\n' "$indent" "$label" done </dev/null 2>&1; then _error "dig is required" return 3 fi local verb="${args[0]}" local -a rest; rest=("${args[@]:1}") # help routing: per-verb help if a verb is in play, else top-level. # -h among a verb's own args (spf find -h) is treated as "show my help" local a local verb_help="" for a in "${rest[@]}"; do case "$a" in -h|--help) verb_help=1 ;; esac done if [ -n "$verb" ] && { [ -n "$verb_help" ] || [ -n "$WANT_HELP" ]; }; then _show_help "$verb"; return 0 fi if [ -z "$verb" ]; then if [ -n "$WANT_HELP" ]; then _show_help; return 0; fi _error "Must provide a verb (show|find|has|flatten|check|tree|ir) or a domain. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi case "$verb" in show) _cmd_show "${rest[@]}" ;; find) _cmd_find "${rest[@]}" ;; has) _cmd_has "${rest[@]}" ;; flatten) _cmd_flatten "${rest[@]}" ;; check) _cmd_check "${rest[@]}" ;; tree) _cmd_tree "${rest[@]}" ;; ir) _resolve "${rest[@]}" ;; # __ip4: test-only -- exercises _ip4_in_cidr directly __ip4) _ip4_in_cidr "${rest[@]}"; return $? ;; # Bare `spf ` (no verb) is shorthand for `spf show `. # Route to show when the lone arg looks like an SPF *subject* -- a domain # (has a dot), a raw record (v=spf1...), or stdin (-) -- so plain # `spf example.com` works, and a bare raw/stdin call still reaches show's # redirect-to-check message instead of a bare "Unknown argument". A token # that is none of these is far likelier a mistyped verb, so keep the sharp # error. The whole arg (verb + rest) is forwarded so `spf domain ip` lands # in show's two-positional guard ("did you mean find") *.*|v=spf1*|-) _cmd_show "$verb" "${rest[@]}" ;; *) _error "Unknown argument '$verb'. Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac ) _spf "$@" __spf_rc=$? unset -f _spf if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __spf_rc; return $__spf_rc" fi eval "unset __spf_rc; exit $__spf_rc"