From 9959f86295232fba1d0bda883c3a7b50f265abc3 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Fri, 4 Sep 2026 12:10:46 -0500 Subject: [PATCH] feat(nuisance): edge nuisance-block sweep + founder-directed blocks [#796] Cloudron-native enforcement (ipset cloudron_blocklist via panel setting). https://projects.knownelement.com/issues/796 --- perf/nuisance/README.md | 53 ++++++++++ perf/nuisance/allowlist.txt | 6 ++ perf/nuisance/blocklist.txt | 67 ++++++++++++ perf/nuisance/nuisance-detect.service | 7 ++ perf/nuisance/nuisance-detect.sh | 43 ++++++++ perf/nuisance/nuisance-detect.timer | 11 ++ perf/nuisance/nuisance-sweep.sh | 142 ++++++++++++++++++++++++++ 7 files changed, 329 insertions(+) create mode 100644 perf/nuisance/README.md create mode 100644 perf/nuisance/allowlist.txt create mode 100644 perf/nuisance/blocklist.txt create mode 100644 perf/nuisance/nuisance-detect.service create mode 100644 perf/nuisance/nuisance-detect.sh create mode 100644 perf/nuisance/nuisance-detect.timer create mode 100644 perf/nuisance/nuisance-sweep.sh diff --git a/perf/nuisance/README.md b/perf/nuisance/README.md new file mode 100644 index 0000000..b08d703 --- /dev/null +++ b/perf/nuisance/README.md @@ -0,0 +1,53 @@ +# perf/nuisance — edge nuisance-block (my.knownelement.com) + +Runbook for the founder-directed hostile-source block (#796, #787 Option B). +Ops knowledge lives here (runs beside code); the durable audit trail is +Redmine #796 + Discourse t/298. + +## How it works + +- Enforcement is **Cloudron-native**: the blocklist setting + (`firewall_blocklist`) drives ipset `cloudron_blocklist`, referenced by the + INPUT-side `CLOUDRON` chain and FORWARD-side `DOCKER-USER`. Cloudron + re-applies it at boot — no custom firewall persistence to maintain. +- Detection is **dual**: + - `nuisance-sweep.sh` (workstation, daily 09:00 automation): scans Haraka + spamhaus-DENY sources, adds new ones (>=3 denials/24h, cap 64/run) via + the blocklist API, prints every change for the daily report. + - `nuisance-detect.sh` + `nuisance-detect.timer` (host, 07:15 daily, no + secrets): writes ranked candidates to + `/home/yellowtent/platformdata/nuisance/candidates.txt` — cross-check + that detection runs even if the LLM automation misses a day. + +## View current blocks + +- Panel: **my.knownelement.com → Network → IP block list** +- API: `GET https://my.knownelement.com/api/v1/network/blocklist` +- Kernel: `ipset list cloudron_blocklist` (members = what is actually dropped) + +## Unblock something + +1. Add the IP/CIDR to `allowlist.txt` (repo) — the sweep will never re-add it. +2. Remove it from the live setting: GET the blocklist, delete the line, POST + the remainder (POST /api/v1/network/blocklist, body + `{"blocklist": ""}`). Takes effect immediately. +3. Update `blocklist.txt` (repo) and note why in Redmine #796. +4. Verify: `ipset test cloudron_blocklist ` must FAIL after removal. + +## Files + +| file | role | +|------|------| +| `nuisance-sweep.sh` | daily auto-block sweep (workstation, automation-driven) | +| `nuisance-detect.sh` | host-side candidate scan (no secrets) | +| `nuisance-detect.{service,timer}` | systemd arming for the detector | +| `blocklist.txt` | git SoR mirror of the live blocklist | +| `allowlist.txt` | never-block overrides (always wins) | + +## Known limits (v1) + +- IPv4 only. IPv6 sources are logged by Haraka but not blocked. +- The sweep only ever blocks IPs our own mail server already DENIED on + Spamhaus evidence — a listed-but-legit sender is unaffected in practice + (it could not deliver anyway). If a shared IP needs unblocking, follow the + procedure above. diff --git a/perf/nuisance/allowlist.txt b/perf/nuisance/allowlist.txt new file mode 100644 index 0000000..7e20963 --- /dev/null +++ b/perf/nuisance/allowlist.txt @@ -0,0 +1,6 @@ +# nuisance allowlist — entries here are NEVER auto-blocked by the sweep [#796] +# One IP or CIDR per line; '#' comments allowed. Put any IP that must never +# lose connectivity (offices, monitoring probes, key partners) here. +# The sweep reads this file every run; changes take effect next run (or at +# the 09:00 automation pass). +# (empty — add entries as needed) diff --git a/perf/nuisance/blocklist.txt b/perf/nuisance/blocklist.txt new file mode 100644 index 0000000..6c8a706 --- /dev/null +++ b/perf/nuisance/blocklist.txt @@ -0,0 +1,67 @@ +# Cloudron blocklist SoR mirror — my.knownelement.com +# LIVE SOURCE OF TRUTH: Cloudron setting firewall_blocklist +# GET/POST https://my.knownelement.com/api/v1/network/blocklist (admin token) +# panel: Network > IP block list; enforced via ipset cloudron_blocklist on +# INPUT(CLOUDRON) + FORWARD(DOCKER-USER); Cloudron re-applies at boot. +# This file mirrors the live setting (last sync 2026-09-04 12:15 CDT, #796). +# Full-line comments allowed in both. + +# founder-directed 2026-09-04 (#796, #787 root cause): hostile SMTP ranges +92.118.39.0/24 +80.94.92.0/24 + +# sweep-added 2026-09-04 (#796 auto: spamhaus-DENY sources, hits>=MIN_HITS) +213.176.24.83 +193.32.162.9 +193.32.162.71 +158.94.208.191 +195.224.203.50 +161.35.78.82 +3.129.187.38 +18.116.101.220 +34.14.19.55 +45.148.10.25 +45.148.10.26 +45.148.10.29 +45.148.10.30 +45.148.10.31 +45.148.10.34 +45.148.10.35 +45.148.10.36 +45.148.10.37 +45.148.10.39 +45.91.64.7 +142.93.104.135 +203.188.168.251 +35.236.139.55 +58.211.39.86 +102.134.107.34 +104.154.249.213 +111.26.106.117 +111.26.177.216 +111.26.62.42 +111.26.63.85 +111.42.175.101 +111.46.77.2 +116.98.104.138 +131.100.49.158 +155.4.31.215 +164.164.198.32 +168.235.210.157 +177.27.76.210 +179.184.218.49 +180.165.11.113 +186.238.89.142 +187.8.64.94 +197.219.208.58 +218.219.193.151 +218.25.233.22 +221.203.187.222 +34.182.9.35 +34.86.40.181 +35.198.252.246 +36.135.107.57 +45.164.115.86 +46.44.0.185 +61.37.150.6 + diff --git a/perf/nuisance/nuisance-detect.service b/perf/nuisance/nuisance-detect.service new file mode 100644 index 0000000..f44d601 --- /dev/null +++ b/perf/nuisance/nuisance-detect.service @@ -0,0 +1,7 @@ +[Unit] +Description=Nuisance candidate detection (Haraka spamhaus-DENY scan) [#796] +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/bash /home/yellowtent/platformdata/nuisance/nuisance-detect.sh diff --git a/perf/nuisance/nuisance-detect.sh b/perf/nuisance/nuisance-detect.sh new file mode 100644 index 0000000..5bec884 --- /dev/null +++ b/perf/nuisance/nuisance-detect.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# perf/nuisance/nuisance-detect.sh — HOST-side nuisance candidate detection [#796] +# +# Runs on the Cloudron host (systemd timer, 07:15 CDT daily + at boot). +# No secrets, no API: scans the mail container's Haraka log for sources +# DENIED via zen.spamhaus.org in the last 24h and writes a ranked +# candidates file the daily automation cross-checks. Detection is +# independent of the LLM automation; enforcement stays in +# nuisance-sweep.sh (Cloudron blocklist API). +# +# Output: /home/yellowtent/platformdata/nuisance/candidates.txt +# lines: (BLOCKED = already in ipset) + +set -u + +OUT_DIR=/home/yellowtent/platformdata/nuisance +OUT="$OUT_DIR/candidates.txt" +SET_NAME=cloudron_blocklist + +mkdir -p "$OUT_DIR" +TMP=$(mktemp) || exit 1 +trap 'rm -f "$TMP"' EXIT + +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 | sort -rn > "$TMP" + +{ + printf '# nuisance candidates — Haraka spamhaus-DENY sources, 24h, generated %s\n' \ + "$(date '+%F %T %Z')" + printf '# format: (BLOCKED = covered by %s)\n' "$SET_NAME" + while read -r hits ip; do + [ -n "$ip" ] || continue + if ipset test "$SET_NAME" "$ip" > /dev/null 2>&1; then + state=BLOCKED + else + state=seen + fi + printf '%s %s %s\n' "$hits" "$ip" "$state" + done < "$TMP" +} > "$OUT" diff --git a/perf/nuisance/nuisance-detect.timer b/perf/nuisance/nuisance-detect.timer new file mode 100644 index 0000000..3c89ac7 --- /dev/null +++ b/perf/nuisance/nuisance-detect.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily nuisance candidate detection (07:15 local) [#796] + +[Timer] +OnCalendar=*-*-* 07:15:00 +RandomizedDelaySec=300 +Persistent=true +Unit=nuisance-detect.service + +[Install] +WantedBy=timers.target diff --git a/perf/nuisance/nuisance-sweep.sh b/perf/nuisance/nuisance-sweep.sh new file mode 100644 index 0000000..23cde1e --- /dev/null +++ b/perf/nuisance/nuisance-sweep.sh @@ -0,0 +1,142 @@ +#!/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/PFVCluster && 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/PFVCluster} +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 --- +[ -f "$HOME/.creds/cloudron.env" ] || fail "$HOME/.creds/cloudron.env missing" +set -a +# shellcheck disable=SC1091 # creds file path built above +. "$HOME/.creds/cloudron.env" +set +a +[ -n "${CLOUDRON_URL:-}" ] && [ -n "${CLOUDRON_API_TOKEN:-}" ] || fail "creds file lacks CLOUDRON_URL/CLOUDRON_API_TOKEN" +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"