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
This commit is contained in:
@@ -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": "<full text>"}`). Takes effect immediately.
|
||||
3. Update `blocklist.txt` (repo) and note why in Redmine #796.
|
||||
4. Verify: `ipset test cloudron_blocklist <ip>` 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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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: <hits24h> <ip> <BLOCKED|seen> (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: <hits24h> <ip> <BLOCKED|seen> (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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user