#!/usr/bin/env bash # perf/nuisance/nuisance-sweep.sh — daily hostile-source auto-block sweep [#796] # # Founder-directed 2026-09-04 (#787 Option B): scan the mail container's # Haraka log for sources DENIED at connect via zen.spamhaus.org; any source # with >=MIN_HITS denials in 24h is added to the Cloudron-native blocklist # (panel: Network > IP block list; setting firewall_blocklist; enforced via # ipset cloudron_blocklist on INPUT(CLOUDRON) + FORWARD(DOCKER-USER) — # re-applied by Cloudron at boot). # # Guardrails: # - allowlist.txt entries are NEVER added (and reported if conflicts arise) # - RFC1918 / loopback / link-local / Tailscale 100.64/10 / reserved nets # are never blockable # - already-covered sources are skipped (CIDR containment check) # - hard cap MAX_NEW additions per run; every change printed for the report # # Only Spamhaus-corroborated sources are ever blocked: an IP must have been # DENIED by our own mail server on Spamhaus evidence — legitimate senders # are untouched in practice. # # Run (workstation; needs tests/remote.sh chokepoint + ~/.creds/cloudron.env): # cd ~/projects/KNEL/KNELPFVCluster && timeout 300 env VM_IP=my.knownelement.com VM_USER=root \ # bash ~/projects/cloudron/perf/nuisance/nuisance-sweep.sh [--dry] set -u PFV_DIR=${PFV_CLUSTER_DIR:-/home/reachableceo/projects/KNEL/KNELPFVCluster} NUIS_DIR=${NUISANCE_DIR:-/home/reachableceo/projects/cloudron/perf/nuisance} ALLOWLIST="$NUIS_DIR/allowlist.txt" HOST_NAME=my.knownelement.com MIN_HITS=3 MAX_NEW=64 DRY=0 [ "${1:-}" = "--dry" ] && DRY=1 TMP=$(mktemp -d) || exit 1 trap 'rm -rf "$TMP"' EXIT fail() { echo "SWEEP ERROR: $1"; exit 1; } # --- 1. scan Haraka spamhaus-DENY sources through the SSH chokepoint --- cd "$PFV_DIR" || fail "PFVCluster checkout not found at $PFV_DIR" timeout 120 env VM_IP="$HOST_NAME" VM_USER=root bash tests/remote.sh vm \ 'docker logs mail --since 24h 2>&1 | grep -a "plugin=dns-list" | grep -a "retval=DENY" | grep -aoE "host \[[0-9.]+\]" | sed "s/^host \[//; s/\]$//" | sort | uniq -c' \ > "$TMP/scan" || fail "mail log scan failed" deny_lines=$(wc -l < "$TMP/scan") # --- 2. current blocklist from the Cloudron API --- # Creds precedence: pre-exported env → TSGCOO vault via `sm` (sanctioned path, # needs a live sm session) → ~/.creds legacy file (textfiles RETIRED 2026-09-06; # /tmp/cr.env is the documented interim until sm is re-authed). if [ -z "${CLOUDRON_URL:-}" ] || [ -z "${CLOUDRON_API_TOKEN:-}" ]; then if command -v sudo >/dev/null 2>&1; then eval "$(sudo -n -u TSGCOO /data2/TSGCOO/.local/bin/sm env cloudron 2>/dev/null)" || true fi fi if [ -z "${CLOUDRON_URL:-}" ] || [ -z "${CLOUDRON_API_TOKEN:-}" ] && [ -f /tmp/cr.env ]; then set -a # interim token file, documented above # shellcheck disable=SC1091 . /tmp/cr.env set +a export CLOUDRON_URL="${CLOUDRON_URL:-https://my.knownelement.com}" fi if [ -z "${CLOUDRON_URL:-}" ] || [ -z "${CLOUDRON_API_TOKEN:-}" ]; then [ -f "$HOME/.creds/cloudron.env" ] || fail "no Cloudron creds: env empty, sm unavailable, /tmp/cr.env and ~/.creds/cloudron.env missing" set -a # shellcheck disable=SC1091 # creds file path built above . "$HOME/.creds/cloudron.env" set +a fi if [ -z "${CLOUDRON_URL:-}" ] || [ -z "${CLOUDRON_API_TOKEN:-}" ]; then fail "no CLOUDRON_URL/CLOUDRON_API_TOKEN from any source" fi curl -sf --max-time 15 "$CLOUDRON_URL/api/v1/network/blocklist" \ -H "Authorization: Bearer $CLOUDRON_API_TOKEN" \ | jq -r .blocklist > "$TMP/current" || fail "blocklist GET failed" current_entries=$(grep -cvE '^[[:space:]]*(#|$)' "$TMP/current") # --- 3. candidates: hits>=MIN_HITS, IPv4, not allowlisted, not never-net, not already covered --- awk -v min="$MIN_HITS" -v allowf="$ALLOWLIST" -v curf="$TMP/current" ' function ip2int(ip, a) { if (split(ip, a, ".") != 4) return -1 return a[1]*16777216 + a[2]*65536 + a[3]*256 + a[4] } function innet(ip, cidr, n, m, base, mask, size, i, v) { v = ip2int(ip); if (v < 0) return 0 n = split(cidr, m, "/") base = ip2int(m[1]); if (base < 0) return 0 mask = (n == 2) ? m[2] + 0 : 32 size = 4294967296 for (i = 0; i < mask; i++) size /= 2 return v >= base && v < base + size } BEGIN { nn = split("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.168.0.0/16 224.0.0.0/4 240.0.0.0/4", NN, " ") } FILENAME == allowf { if ($1 !~ /^#/) allow[$1] = 1; next } FILENAME == curf { if ($0 !~ /^[[:space:]]*(#|$)/) cur[++nc] = $1; next } { hits = $1 + 0; ip = $2 if (ip2int(ip) < 0) next if (hits < min) next if (ip in allow) { print "SKIP-ALLOW", ip, hits > "/dev/stderr"; next } bad = 0 for (i = 1; i <= nn; i++) if (innet(ip, NN[i])) { bad = 1; break } if (bad) { print "SKIP-RESERVED", ip, hits > "/dev/stderr"; next } for (i = 1; i <= nc; i++) if (innet(ip, cur[i])) { bad = 1; break } if (bad) next print ip, hits } ' "$ALLOWLIST" "$TMP/current" "$TMP/scan" 2>"$TMP/skips" \ | sort -k2,2rn > "$TMP/new" new_count=$(wc -l < "$TMP/new") echo "== nuisance sweep summary ==" echo "haraka spamhaus-DENY sources 24h: $deny_lines distinct" echo "current blocklist entries: $current_entries" echo "new block candidates (hits>=$MIN_HITS, uncovered): $new_count" # --- 4. apply (cap MAX_NEW) --- if [ "$new_count" -eq 0 ]; then echo "SWEEP RESULT: no new blocks needed" exit 0 fi if [ "$new_count" -gt "$MAX_NEW" ]; then echo "SWEEP ABORT: $new_count candidates exceed per-run cap $MAX_NEW — pathological day, human review required" >&2 exit 2 fi stamp=$(date +%F) { echo "# sweep-added $stamp (#796 auto: spamhaus-DENY sources, hits>=MIN_HITS)" awk '{ print $1 }' "$TMP/new" } > "$TMP/addition" if [ "$DRY" -eq 1 ]; then echo "DRY RUN — would append these entries:" cat "$TMP/addition" exit 0 fi { cat "$TMP/current"; echo; cat "$TMP/addition"; } > "$TMP/merged" payload=$(jq -Rs '{ blocklist: . }' "$TMP/merged") resp=$(printf '%s' "$payload" | curl -sf --max-time 30 -X POST \ "$CLOUDRON_URL/api/v1/network/blocklist" \ -H "Authorization: Bearer $CLOUDRON_API_TOKEN" \ -H "Content-Type: application/json" -d @- -w '\n%{http_code}') || fail "blocklist POST failed" echo "$resp" | tail -1 | grep -q 200 || fail "blocklist POST not 200: $resp" # --- 5. verify --- curl -sf --max-time 15 "$CLOUDRON_URL/api/v1/network/blocklist" \ -H "Authorization: Bearer $CLOUDRON_API_TOKEN" \ | jq -r .blocklist > "$TMP/after" || fail "verify GET failed" verified=$(grep -cvE '^[[:space:]]*(#|$)' "$TMP/after") expected=$((current_entries + new_count)) [ "$verified" -eq "$expected" ] || fail "verify mismatch: expected $expected entries, got $verified" echo "SWEEP RESULT: BLOCKED $new_count new source(s):" cat "$TMP/new" echo "blocklist now $verified entries"