commit dd60107020fc70bb70f02c3d44fcb48c18e8d5cd Author: eric Date: Sat Feb 21 16:13:31 2026 +0000 Add smtp-test.sh diff --git a/smtp-test.sh b/smtp-test.sh new file mode 100644 index 0000000..0553244 --- /dev/null +++ b/smtp-test.sh @@ -0,0 +1,252 @@ +#!/bin/bash +# ============================================================ +# Outbound SMTP/25 test + interface sweep + logging + rDNS/HELO +# +# Outputs: ./results.txt (appends) +# +# Usage: +# ./smtp25_sweep.sh +# ./smtp25_sweep.sh --domains "gmail.com outlook.com yahoo.com icloud.com proton.me" +# ./smtp25_sweep.sh --targets "gmail-smtp-in.l.google.com:25 outlook-com.olc.protection.outlook.com:25" +# +# Notes: +# - Checks TCP/25 connectivity from ALL global IPv4 addresses on the host +# - Resolves MX for domains (takes top MX by preference) unless explicit targets given +# - Validates rDNS (PTR exists) and forward-confirmed PTR (A of PTR contains the IP) +# - Sends EHLO and records server response (best-effort HELO sanity) +# ============================================================ + +set -u + +TIMEOUT=6 +LOGFILE="./results.txt" + +DEFAULT_DOMAINS=("gmail.com" "outlook.com" "yahoo.com" "icloud.com" "proton.me") # +2 included (icloud, proton) +DOMAINS=("${DEFAULT_DOMAINS[@]}") +TARGETS=() # host:port entries; if provided, skips MX resolution + +HELO_NAME="" +if command -v postconf >/dev/null 2>&1; then + HELO_NAME="$(postconf -h myhostname 2>/dev/null || true)" +fi +if [[ -z "${HELO_NAME}" ]]; then + HELO_NAME="$(hostname -f 2>/dev/null || hostname)" +fi + +ts() { date +"%Y-%m-%d %H:%M:%S %z"; } + +log() { + echo "[$(ts)] $*" | tee -a "$LOGFILE" >/dev/null +} + +die() { + log "FATAL: $*" + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +need_cmd ip +need_cmd nc +need_cmd dig +need_cmd awk +need_cmd sort +need_cmd sed +need_cmd grep + +# --------------------- args --------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --domains) + shift + IFS=' ' read -r -a DOMAINS <<< "${1:-}" + ;; + --targets) + shift + IFS=' ' read -r -a TARGETS <<< "${1:-}" + ;; + --timeout) + shift + TIMEOUT="${1:-6}" + ;; + --log) + shift + LOGFILE="${1:-./results.txt}" + ;; + *) + echo "Unknown arg: $1" + exit 2 + ;; + esac + shift +done + +# --------------------- helpers --------------------- +get_source_ips() { + # global IPv4 addresses + ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | sed 's#/.*##' | sort -u +} + +mx_for_domain() { + local d="$1" + # Return MX hosts sorted by preference (lowest first), without trailing dot + dig +short MX "$d" 2>/dev/null | sort -n | awk '{print $2}' | sed 's/\.$//' | grep -v '^$' || true +} + +resolve_targets_from_domains() { + local t=() + for d in "${DOMAINS[@]}"; do + local mxlist + mxlist="$(mx_for_domain "$d")" + if [[ -z "$mxlist" ]]; then + log "WARN: No MX found for domain: $d" + continue + fi + # Take top 2 MX for each domain to reduce noise; adjust if you want more. + local count=0 + while IFS= read -r mx; do + t+=("${mx}:25") + count=$((count+1)) + [[ $count -ge 2 ]] && break + done <<< "$mxlist" + done + + # de-dup + printf "%s\n" "${t[@]}" | sort -u +} + +rdns_check_ip() { + local ipaddr="$1" + + local ptr + ptr="$(dig +short -x "$ipaddr" 2>/dev/null | head -n 1 | sed 's/\.$//')" + + if [[ -z "$ptr" ]]; then + log "rDNS: $ipaddr -> PTR: (none) [FAIL]" + return + fi + + # Forward-confirmed reverse DNS (FCrDNS): A/AAAA of PTR contains original IP + local a_records + a_records="$(dig +short A "$ptr" 2>/dev/null | tr '\n' ' ')" + + if echo "$a_records" | grep -q -w "$ipaddr"; then + log "rDNS: $ipaddr -> PTR: $ptr ; A(PTR): $a_records [OK FCrDNS]" + else + log "rDNS: $ipaddr -> PTR: $ptr ; A(PTR): $a_records [WARN mismatch]" + fi +} + +tcp25_test() { + local srcip="$1" + local host="$2" + local port="$3" + + log "TEST: src=$srcip -> $host:$port (tcp/$port) timeout=${TIMEOUT}s" + + # Connection + banner (best effort) + local out rc + out="$(nc -v -w "$TIMEOUT" -s "$srcip" "$host" "$port" < /dev/null 2>&1)" + rc=$? + + if [[ $rc -eq 0 ]]; then + log "RESULT: CONNECTED" + # capture banner line if present + local banner + banner="$(echo "$out" | grep -E '^[0-9]{3} ' | head -n 1 || true)" + [[ -n "$banner" ]] && log "BANNER: $banner" + else + # common error strings: timed out, refused, no route, network unreachable + log "RESULT: FAILED rc=$rc" + log "DETAIL: $(echo "$out" | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-350)" + fi +} + +ehlo_probe() { + local srcip="$1" + local host="$2" + local port="$3" + + log "EHLO: src=$srcip -> $host:$port using HELO='$HELO_NAME'" + + # Speak minimal SMTP: EHLO + QUIT + # Some servers may tarp it; keep timeout short. + local resp + resp="$( + { + printf "EHLO %s\r\n" "$HELO_NAME" + printf "QUIT\r\n" + } | nc -w "$TIMEOUT" -s "$srcip" "$host" "$port" 2>/dev/null | head -n 20 + )" + + if [[ -z "$resp" ]]; then + log "EHLO: (no response captured) [WARN]" + return + fi + + # Log first lines (avoid huge output) + while IFS= read -r line; do + line="$(echo "$line" | tr -d '\r')" + log "EHLO-RESP: $line" + done <<< "$resp" +} + +# --------------------- main --------------------- +log "============================================================" +log "START smtp25 sweep on host=$(hostname) helo=$HELO_NAME" +log "LOGFILE=$LOGFILE" + +SOURCE_IPS=() +while IFS= read -r ipaddr; do + [[ -n "$ipaddr" ]] && SOURCE_IPS+=("$ipaddr") +done < <(get_source_ips) + +if [[ ${#SOURCE_IPS[@]} -eq 0 ]]; then + die "No global IPv4 addresses found to test from." +fi + +log "SOURCE IPs: ${SOURCE_IPS[*]}" + +# rDNS checks (per source IP) +log "---------------- rDNS validation ----------------" +for sip in "${SOURCE_IPS[@]}"; do + rdns_check_ip "$sip" +done + +# targets +log "---------------- targets ----------------" +if [[ ${#TARGETS[@]} -gt 0 ]]; then + log "Using explicit targets: ${TARGETS[*]}" + FINAL_TARGETS=("${TARGETS[@]}") +else + mapfile -t FINAL_TARGETS < <(resolve_targets_from_domains) + if [[ ${#FINAL_TARGETS[@]} -eq 0 ]]; then + die "No targets resolved from domains: ${DOMAINS[*]}" + fi + log "Resolved targets (top MXs): ${FINAL_TARGETS[*]}" +fi + +# connectivity tests +log "---------------- TCP/25 connectivity ----------------" +for sip in "${SOURCE_IPS[@]}"; do + for hp in "${FINAL_TARGETS[@]}"; do + host="${hp%:*}" + port="${hp##*:}" + tcp25_test "$sip" "$host" "$port" + done +done + +# EHLO probes (optional but requested) +log "---------------- EHLO probes (best-effort) ----------------" +for sip in "${SOURCE_IPS[@]}"; do + for hp in "${FINAL_TARGETS[@]}"; do + host="${hp%:*}" + port="${hp##*:}" + ehlo_probe "$sip" "$host" "$port" + done +done + +log "END smtp25 sweep" +log "============================================================" \ No newline at end of file