Files
mrcharles 583d499b2c nuisance: creds chain env > sm vault > legacy file
sm re-auth pending (#925); /tmp/cr.env is the documented interim.

Redmine: https://projects.knownelement.com/issues/925
2026-09-07 09:16:52 -05:00

163 lines
6.8 KiB
Bash

#!/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"