nas16-scripts/CrowdSec/crowdsec_digest_summary.html

528 lines
21 KiB
HTML
Raw Permalink Normal View History

2026-07-26 21:21:03 -04:00
<!DOCTYPE html>
<!--
=============================================================
KINGDEZIGNS — CONVERSATION SUMMARY TEMPLATE
=============================================================
HOW TO USE:
Give this file to Claude at the end of any troubleshooting
session and say:
"Use my summary template to document this conversation."
Claude will fill in all sections below based on what
actually happened in the conversation. Remove or add
sections as needed per session. Sections marked OPTIONAL
can be deleted if not applicable.
=============================================================
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KingDezigns — CrowdSec Local Alert Digest Summary</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f7f6f2;
--surface: #ffffff;
--surface-alt: #f1f0eb;
--border: #e2e0d8;
--border-strong: #c8c5ba;
--text-primary: #1a1917;
--text-secondary: #6b6960;
--text-muted: #9b9890;
--green-bg: #eaf3de;
--green-border: #97c459;
--green-text: #2d5a0e;
--red-bg: #fcebeb;
--red-border: #f09595;
--red-text: #7a1f1f;
--amber-bg: #faeeda;
--amber-border: #fac775;
--amber-text: #633806;
--coral-bg: #faece7;
--coral-border: #f0997b;
--coral-text: #4a1b0c;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
body {
font-family: var(--sans);
font-size: 14px;
background: var(--bg);
color: var(--text-primary);
line-height: 1.7;
padding: 2.5rem 1.5rem;
}
.page { max-width: 860px; margin: 0 auto; }
header {
border-bottom: 1px solid var(--border-strong);
padding-bottom: 1.25rem;
margin-bottom: 2rem;
}
header h1 {
font-family: var(--mono);
font-size: 18px;
font-weight: 500;
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.meta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.section { margin-bottom: 2.25rem; }
.section-title {
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: attr(data-num);
background: var(--text-primary);
color: var(--bg);
font-size: 10px;
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
p { margin-bottom: 0.75rem; color: var(--text-primary); }
p:last-child { margin-bottom: 0; }
.badge-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 1rem; }
.badge {
font-family: var(--mono);
font-size: 11px;
padding: 3px 10px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
}
.badge.red { background: var(--red-bg); color: var(--red-text); border-color: var(--red-border); }
.badge.green { background: var(--green-bg); color: var(--green-text); border-color: var(--green-border); }
.badge.amber { background: var(--amber-bg); color: var(--amber-text); border-color: var(--amber-border); }
.step {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 0.6rem;
}
.step-header {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 8px;
}
.step-num {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 50%;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
flex-shrink: 0;
color: var(--text-muted);
}
pre {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
overflow-x: auto;
margin: 0.5rem 0;
white-space: pre;
line-height: 1.6;
}
code {
font-family: var(--mono);
font-size: 12px;
background: var(--surface-alt);
padding: 1px 5px;
border-radius: 3px;
border: 1px solid var(--border);
}
.why {
font-size: 12px;
color: var(--text-muted);
margin-top: 0.4rem;
padding-left: 2px;
}
.why::before { content: '↳ why: '; font-family: var(--mono); }
.result { font-size: 12px; margin-top: 0.35rem; padding-left: 2px; }
.result.pass { color: var(--green-text); }
.result.pass::before { content: '✓ '; font-family: var(--mono); }
.result.fail { color: var(--red-text); }
.result.fail::before { content: '✗ '; font-family: var(--mono); }
.result.neutral { color: var(--text-secondary); }
.result.neutral::before { content: '→ '; font-family: var(--mono); }
.callout { border-radius: 6px; padding: 1rem 1.25rem; margin-bottom: 0.75rem; }
.callout.coral { background: var(--coral-bg); border: 1px solid var(--coral-border); }
.callout.coral p { color: var(--coral-text); }
.callout.coral pre { background: #fdf0eb; border-color: var(--coral-border); }
.callout.amber { background: var(--amber-bg); border: 1px solid var(--amber-border); }
.callout.amber p { color: var(--amber-text); }
.callout.amber pre { background: #fdf5e6; border-color: var(--amber-border); }
.callout.amber code { background: #fdf5e6; border-color: var(--amber-border); color: var(--amber-text); }
.callout.green { background: var(--green-bg); border: 1px solid var(--green-border); }
.callout.green p { color: var(--green-text); }
.callout.green pre { background: #d8efc0; border-color: var(--green-border); }
.clue-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--amber-text);
margin-bottom: 8px;
}
.script-box { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.script-row { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); }
.script-row:last-child { border-bottom: none; }
.script-label { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); margin-bottom: 6px; }
.script-row pre { margin: 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
th { text-align: left; padding: 8px 14px; font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); background: var(--surface-alt); border-bottom: 1px solid var(--border); font-weight: 500; }
td { padding: 9px 14px; border-bottom: 1px solid var(--border); vertical-align: top; line-height: 1.5; }
tr:last-child td { border-bottom: none; }
.footer {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
/* TEMPLATE ONLY — highlights placeholder text in browser, invisible in final output */
.placeholder {
background: #fff3cd;
border: 1px dashed #ffc107;
border-radius: 3px;
padding: 1px 6px;
font-family: var(--mono);
font-size: 12px;
color: #856404;
}
</style>
</head>
<body>
<div class="page">
<!-- =============================================
HEADER
Fill in: topic title, date, system/device info
============================================= -->
<header>
<h1>KingDezigns — CrowdSec Local Alert Digest summary</h1>
<div class="meta">
<span>📅 2026-06-09</span>
<span>🖥 NAS16 (CrowdSec notifier) / HAS (CrowdSec LAPI)</span>
<span>⚙️ OpenMediaVault / OMV Scheduled Jobs</span>
<span>🐳 Bash + Python3 + SMTP_SSL</span>
<span>☁️ CrowdSec LAPI stream</span>
</div>
</header>
<!-- =============================================
SECTION 1 — THE PROBLEM
Describe what broke, what the symptoms were,
and what changed before it broke.
============================================= -->
<div class="section">
<div class="section-title" data-num="1">The problem</div>
<p>The CrowdSec local notifier (<code>cs_notifier.sh</code>) sent an immediate, fully-styled HTML email for every single locally-detected attack ban (origin = <code>crowdsec</code>). During active scanning/probing periods this produced dozens to hundreds of emails per week, which was far more volume than expected and made the inbox unmanageable.</p>
<div class="badge-row">
<span class="badge amber">"CrowdSec LIVE ATTACK Detected on HAS - $IP" (one email per ban)</span>
<span class="badge amber">Hundreds of emails received weekly</span>
</div>
<p>No infrastructure change caused this — it was the original by-design behavior of the notifier (immediate per-decision alerting), which became unmanageable at the observed local attack volume.</p>
</div>
<!-- =============================================
SECTION 2 — DIAGNOSTIC COMMANDS
One .step block per command or command group.
Only include commands that contributed to
the diagnosis. Remove dead ends.
.result classes:
pass = confirmed something working (green)
fail = confirmed something broken (red)
neutral = ruled something out (gray)
============================================= -->
<div class="section">
<div class="section-title" data-num="2">Build &amp; test steps</div>
<div class="step">
<div class="step-header"><span class="step-num">1</span> Reviewed original cs_notifier.sh</div>
<pre>Reviewed full per-IP email-sending loop in cs_notifier.sh</pre>
<p class="why">Needed to understand existing LAPI polling, origin routing, and email format before changing behavior</p>
<p class="result neutral">Confirmed: every "crowdsec" origin decision triggered an immediate styled HTML email; CAPI decisions were already batched into a midnight digest</p>
</div>
<div class="step">
<div class="step-header"><span class="step-num">2</span> Split notifier into polling + digest</div>
<pre>cs_notifier.sh: append local "crowdsec" decisions to local_pending.json (no email)
cs_local_digest.sh (new): read local_pending.json, send one batched email, clear log</pre>
<p class="why">Decouples fast polling (every 5 min) from email sending (every 4 hours) so volume drops without losing any ban data</p>
<p class="result pass">cs_notifier.sh simplified — CAPI routing logic unchanged, local routing now just appends to a pending file</p>
</div>
<div class="step">
<div class="step-header"><span class="step-num">3</span> Manual test — inject fake ban</div>
<pre>sudo bash -c 'echo '"'"'{"value":"1.2.3.4","scenario":"crowdsecurity/http-probing","duration":"4h","type":"ban","scope":"Ip","origin":"crowdsec","start_at":"2026-06-09T14:00:00Z"}'"'"' >> /var/log/crowdsec-notifier/local_pending.json'
sudo bash /usr/scripts/crowdsec/cs_local_digest.sh
sudo tail -20 /var/log/crowdsec-notifier/notifier.log</pre>
<p class="why">Verify digest builds, sends, and clears the pending log without waiting for a real attack</p>
<p class="result fail">First attempts: email format didn't match expectations, and the "Banned IPs — Past 4 Hours" table rendered with an empty body — IP rows were missing entirely</p>
</div>
<div class="step">
<div class="step-header"><span class="step-num">4</span> Isolated row-generation bug</div>
<pre>echo "$SNAPSHOT" | python3 - &lt;&lt; 'PYEOF'
... (reads sys.stdin) ...
PYEOF</pre>
<p class="why">Table rows were empty even though local_pending.json had data — needed to isolate whether the bug was in JSON parsing or in HTML assembly</p>
<p class="result fail">Confirmed: this pattern produced zero output. A heredoc attached to <code>python3 -</code> becomes stdin itself, silently overriding the piped data — <code>sys.stdin</code> in Python was reading the heredoc body, not $SNAPSHOT</p>
</div>
<div class="step">
<div class="step-header"><span class="step-num">5</span> Fixed with inline variable substitution</div>
<pre>python3 &lt;&lt; PYEOF
snapshot = """$SNAPSHOT"""
for line in snapshot.strip().splitlines():
...
PYEOF</pre>
<p class="why">Pass $SNAPSHOT directly into the heredoc as a shell-expanded Python string instead of piping</p>
<p class="result pass">Re-ran full test — table populated correctly with IP, scenario, type, duration, scope, detected time, and CTI/WHOIS/AbuseIPDB links; log confirmed send + local_pending.json cleared</p>
</div>
</div>
<!-- =============================================
SECTION 3 — ROOT CAUSE
Use the coral callout for the cause statement.
Use the amber callout for any key clue or
missed signal.
Use the green callout for the resolution.
============================================= -->
<div class="section">
<div class="section-title" data-num="3">Root cause</div>
<div class="callout coral">
<p><strong>Notification volume:</strong> the original notifier sent one full HTML email per locally-detected ban with no batching, which scales linearly with attack frequency and quickly becomes unmanageable.</p>
</div>
<div class="callout amber">
<div class="clue-label">⚠ Key clue (table-rendering bug during testing)</div>
<p style="margin-bottom:6px;">The "Banned IPs — Past 4 Hours" table rendered with headers but an empty &lt;tbody&gt;. The script logged a non-zero pending count, so data existed — the failure was in row generation, not data collection.</p>
<pre>echo "$SNAPSHOT" | python3 - &lt;&lt; 'PYEOF'
import sys, json
for line in sys.stdin: # <- reads the HEREDOC body, not $SNAPSHOT
...
PYEOF</pre>
<p style="margin-top:8px;">When a heredoc is attached directly to <code>python3 -</code>, the heredoc body becomes the process's stdin, completely overriding any piped input. <code>sys.stdin</code> received the (empty) heredoc content instead of $SNAPSHOT, so the loop never executed.</p>
</div>
<div class="callout green" style="margin-top:0.75rem;">
<p><strong>Resolution:</strong> Replaced immediate per-ban emails with a 5-minute polling script that appends local bans to <code>local_pending.json</code>, plus a new <code>cs_local_digest.sh</code> that runs every 4 hours, builds one batched email (stat cards + full IP table), sends it, and clears the log — and fixed the row-generation bug by passing <code>$SNAPSHOT</code> as an inline Python variable inside the heredoc instead of piping it.</p>
</div>
</div>
<!-- Section 4 (Secondary issues) not applicable for this session -->
<!-- =============================================
SECTION 5 — SCRIPTS / FILES CREATED (OPTIONAL)
Use this section if any scripts, config files,
or tools were created during the session.
============================================= -->
<div class="section">
<div class="section-title" data-num="5">Scripts / files created</div>
<div class="script-box">
<div class="script-row">
<div class="script-label">Replace (updated)</div>
<pre>/usr/scripts/crowdsec/cs_notifier.sh</pre>
</div>
<div class="script-row">
<div class="script-label">Add (new)</div>
<pre>/usr/scripts/crowdsec/cs_local_digest.sh</pre>
</div>
<div class="script-row">
<div class="script-label">Setup</div>
<pre>chmod +x /usr/scripts/crowdsec/cs_notifier.sh
chmod +x /usr/scripts/crowdsec/cs_local_digest.sh
mkdir -p /var/log/crowdsec-notifier</pre>
</div>
<div class="script-row">
<div class="script-label">Run — cs_notifier.sh (existing OMV job, unchanged schedule)</div>
<pre>Every 5 minutes:
bash /usr/scripts/crowdsec/cs_notifier.sh</pre>
</div>
<div class="script-row">
<div class="script-label">Run — cs_local_digest.sh (new OMV scheduled job)</div>
<pre>Every 4 hours (cron: 0 0,4,8,12,16,20 * * *):
bash /usr/scripts/crowdsec/cs_local_digest.sh</pre>
</div>
<div class="script-row">
<div class="script-label">Manual test</div>
<pre>sudo bash -c 'echo '"'"'{"value":"1.2.3.4","scenario":"crowdsecurity/http-probing","duration":"4h","type":"ban","scope":"Ip","origin":"crowdsec","start_at":"2026-06-09T14:00:00Z"}'"'"' >> /var/log/crowdsec-notifier/local_pending.json'
sudo bash /usr/scripts/crowdsec/cs_local_digest.sh
sudo tail -20 /var/log/crowdsec-notifier/notifier.log</pre>
</div>
</div>
</div>
<!-- =============================================
SECTION 6 — LESSONS LEARNED
Keep entries short — one lesson per row.
============================================= -->
<div class="section">
<div class="section-title" data-num="6">Lessons learned</div>
<table>
<thead>
<tr><th>Lesson</th><th>Detail</th></tr>
</thead>
<tbody>
<tr>
<td>Batch high-frequency alerts</td>
<td>Per-event email alerting doesn't scale with attack frequency. A 4-hour batched digest preserves full visibility (every IP, scenario, type, duration, scope, and threat-intel links) while cutting email volume from hundreds/week to a handful/day.</td>
</tr>
<tr>
<td>Skip-if-empty for digests</td>
<td>cs_local_digest.sh exits silently with no email if local_pending.json is empty — avoids inbox clutter during quiet periods.</td>
</tr>
<tr>
<td>Snapshot-before-clear pattern</td>
<td>The pending log is only cleared (<code>&gt; local_pending.json</code>) after a confirmed successful SMTP send. If the send fails, the data stays intact and is retried on the next 4-hour run.</td>
</tr>
<tr>
<td>python3 - with a heredoc ignores piped stdin</td>
<td><code>echo "$DATA" | python3 - &lt;&lt; EOF ... EOF</code> does NOT work as expected — the heredoc body becomes stdin and the pipe is discarded. Pass shell data into Python via inline variable interpolation inside the heredoc instead (<code>var = """$DATA"""</code>).</td>
</tr>
<tr>
<td>Iterating on email templates</td>
<td>Several format iterations were needed to match the desired layout exactly. Keeping the rendered HTML output from a real test send was the fastest way to confirm/correct structure.</td>
</tr>
</tbody>
</table>
</div>
<!-- =============================================
SECTION 7 — NETWORK MAP / DOCS TO UPDATE (OPTIONAL)
List anything that changed and needs to be
reflected in your architecture map or docs.
============================================= -->
<div class="section">
<div class="section-title" data-num="7">Network map — items to update</div>
<table>
<thead>
<tr><th>Item</th><th>Old value</th><th>New value</th></tr>
</thead>
<tbody>
<tr>
<td>cs_notifier.sh behavior</td>
<td><code>Sends immediate per-IP email on each local ban</code></td>
<td><code>Appends local bans to local_pending.json (no email)</code></td>
</tr>
<tr>
<td>New scheduled job</td>
<td><code>(none)</code></td>
<td><code>cs_local_digest.sh every 4 hours — cron: 0 0,4,8,12,16,20 * * *</code></td>
</tr>
<tr>
<td>New log file</td>
<td><code>(none)</code></td>
<td><code>/var/log/crowdsec-notifier/local_pending.json</code></td>
</tr>
<tr>
<td>server_nas16.md</td>
<td><code>2-script architecture (cs_notifier.sh, cs_digest.sh)</code></td>
<td><code>3-script architecture — updated (cs_notifier.sh, cs_local_digest.sh, cs_digest.sh)</code></td>
</tr>
</tbody>
</table>
</div>
<!-- FOOTER — date and network name auto-filled by Claude -->
<div class="footer">
<span>KingDezigns Home Network</span>
<span>Generated 2026-06-09</span>
</div>
</div>
</body>
</html>