PeerCortex/audit/send_audit_report.py
Rene Fichtmueller 2766872aad snapshot: preserve production-only changes (Izzy PDF export, adoption tracker, audit scripts)
Working tree was 3+ months ahead of git in uncommitted local edits, sitting
on a detached HEAD at d3611a8 (2026-04-09), never reconciled with main.
Committing as-is on its own branch, without touching main or the detached
HEAD, to stop these files existing only on this one server:

- public/index.html + server.js: PDF export add-on gated to client Izzy,
  full Adoption Tracker overlay (ASPA + IPv6 tabs) -- neither exists
  anywhere in git history on any branch until now
- audit/: daily/rotating audit scripts + email report sender, previously
  untracked
- scripts/: tunnel-cleanup.sh, refresh-peeringdb.sh, previously untracked
- deploy-from-scp.sh, webhook-subs.json, hijack-alerts.json,
  aspa-adoption-history.json: previously untracked runtime/deploy state
- public/public/: duplicate mirror of the HTML variants found alongside
  the real public/ dir, preserved as found

No reconciliation with main attempted here -- that needs a deliberate pass,
not a side effect of a backup commit.
2026-07-16 06:42:03 +00:00

227 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""PeerCortex Daily Audit Mailer — detailed report via localhost:25"""
import os, json, smtplib
from datetime import datetime, timezone, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import make_msgid, formatdate
SMTP_HOST = "localhost"
SMTP_PORT = 25
MAIL_FROM = "ctx-report@fichtmueller.org"
MAIL_TO = "rf@flexoptix.net"
def td(content, color=None, align="left", mono=False, bold=False):
ff = "monospace" if mono else "sans-serif"
fw = "font-weight:700;" if bold else ""
cl = "color:%s;" % color if color else ""
return '<td style="font-family:%s;font-size:11px;padding:3px 8px;text-align:%s;%s%s">%s</td>' % (ff, align, cl, fw, content)
def load_report():
for delta in [0, 1]:
d = (datetime.now(timezone.utc) - timedelta(days=delta)).strftime("%Y-%m-%d")
try:
with open("/var/log/peercortex/audit-%s.json" % d) as f:
return json.load(f), d
except FileNotFoundError:
continue
return None, datetime.now(timezone.utc).strftime("%Y-%m-%d")
def build(report, date_str):
if not report:
return ("<html><body style='background:#0f172a;color:#e2e8f0;padding:24px'><h2>No report for %s</h2></body></html>" % date_str,
"PeerCortex Audit %s — no report" % date_str)
counts = report.get("counts", {})
results = report.get("results", [])
elapsed = report.get("elapsed_s", 0)
pool = report.get("pool_size", 0)
ok = counts.get("ok", 0)
warn = counts.get("warning", 0)
crit = counts.get("critical", 0)
skip = counts.get("skip", 0)
total = len(results)
mins, secs = elapsed // 60, elapsed % 60
if crit == 0 and warn == 0:
sc, emoji, text, spfx = "#22c55e", "", "All Clear — no issues detected", "✅ All Clear"
elif crit == 0:
sc, emoji, text, spfx = "#f97316", "⚠️", "%d warning(s)" % warn, "⚠️ %d Warning(s)" % warn
else:
sc, emoji, text, spfx = "#ef4444", "🚨", "%d CRITICAL issue(s) — action required" % crit, "🚨 %d CRITICAL" % crit
subject = "PeerCortex Daily Audit %s%s" % (date_str, spfx)
# Issues
issue_results = [r for r in results if r.get("status") in ("critical", "warning")]
issues_html = ""
if issue_results:
rows = ""
for r in issue_results:
asn = str(r["asn"])
name = (r.get("data", {}).get("name") or "?")[:28]
for sev, field, msg in r.get("issues", []):
c = "#ef4444" if sev == "CRITICAL" else "#f97316"
rows += "<tr style='border-bottom:1px solid #1e293b'>%s%s%s%s%s</tr>" % (
td("[%s]" % sev[:4], c, mono=True),
td("AS" + asn, "#94a3b8", mono=True),
td(name, "#e2e8f0"),
td(field, "#64748b", mono=True),
td(msg[:60], "#94a3b8"))
issues_html = (
"<h3 style='color:#f97316;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>⚠ Issues (%d ASNs)</h3>" % len(issue_results) +
"<table style='width:100%%;border-collapse:collapse;background:#111827;border-radius:6px;overflow:hidden'>%s</table>" % rows
)
# Skipped
skip_results = [r for r in results if r.get("status") == "skip"]
skipped_html = ""
if skip_results:
rows = ""
for r in skip_results:
asn = str(r["asn"])
reason = r.get("issues", [["SKIP","","no response from PeerCortex"]])[0][2] if r.get("issues") else "no response from PeerCortex"
url = "https://peercortex.org/?asn=" + asn
rows += ("<tr style='border-bottom:1px solid #1e293b'>%s%s<td style='font-size:11px;padding:3px 8px'>"
"<a href='%s' style='color:#3b82f6;text-decoration:none'>→ lookup</a></td></tr>") % (
td("AS" + asn, "#94a3b8", mono=True),
td(reason, "#64748b"),
url)
skipped_html = (
"<h3 style='color:#475569;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>⏭ Skipped ASNs (%d) — no PeerCortex response</h3>" % skip +
"<p style='color:#475569;font-size:11px;margin:0 0 8px'>Possible causes: ASN not in any routing table, very large prefix count, or temporary API timeout.</p>" +
"<table style='width:100%%;border-collapse:collapse;background:#111827;border-radius:6px;overflow:hidden'>%s</table>" % rows
)
# Low RPKI
rpki_bad = sorted(
[r for r in results if r.get("status") != "skip" and r.get("data", {}).get("rpki_pct", 100) < 60],
key=lambda r: r.get("data", {}).get("rpki_pct", 100)
)[:8]
rpki_html = ""
if rpki_bad:
rows = ""
for r in rpki_bad:
d = r.get("data", {})
pct = d.get("rpki_pct", 0)
bar = ("<div style='background:#1e293b;border-radius:3px;height:8px;width:100px;display:inline-block'>"
"<div style='background:#ef4444;border-radius:3px;height:8px;width:%d%%'></div></div>") % max(2, pct)
rows += "<tr style='border-bottom:1px solid #1e293b'>%s%s%s<td style='padding:3px 8px'>%s</td>%s</tr>" % (
td("AS" + str(r["asn"]), "#94a3b8", mono=True),
td((d.get("name") or "?")[:28], "#e2e8f0"),
td(d.get("rir", "?"), "#64748b", "center"),
bar,
td(str(pct) + "%", "#ef4444", "right", bold=True))
rpki_html = (
"<h3 style='color:#94a3b8;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>📉 Low RPKI Coverage (&lt;60%%)</h3>" +
"<table style='width:100%%;border-collapse:collapse;background:#111827;border-radius:6px;overflow:hidden'>%s</table>" % rows
)
# All ASNs table
sorted_results = sorted([r for r in results if r.get("status") != "skip"], key=lambda x: x["asn"])
header = ("<tr style='background:#1e293b'>%s%s%s%s%s%s%s%s%s%s%s</tr>" % (
td("ASN", "#64748b", "right", mono=True),
td("Name", "#64748b"),
td("RIR", "#64748b", "center"),
td("CC", "#64748b", "center"),
td("Pfx", "#64748b", "right"),
td("RIPE Stat", "#64748b", "right"),
td("RPKI%", "#64748b", "right"),
td("IX", "#64748b", "right"),
td("PDB IX", "#64748b", "right"),
td("Fac", "#64748b", "right"),
td("Status", "#64748b", "center")))
rows = ""
for i, r in enumerate(sorted_results):
d = r.get("data", {})
bg = "#0f172a" if i % 2 == 0 else "#111827"
st = r.get("status", "ok")
if st == "critical":
st_html = '<span style="color:#ef4444;font-weight:700">✗ CRIT</span>'
elif st == "warning":
st_html = '<span style="color:#f97316">⚠ WARN</span>'
else:
st_html = '<span style="color:#22c55e">✓</span>'
pct = d.get("rpki_pct", 100)
rpki_c = "#ef4444" if pct < 50 else "#f97316" if pct < 80 else "#94a3b8"
ripe = str(d.get("ripe_stat_pfx", ""))
pdb = str(d.get("pdb_ix_cnt", ""))
rows += "<tr style='background:%s'>%s%s%s%s%s%s%s%s%s%s<td style='font-size:11px;padding:3px 8px;text-align:center'>%s</td></tr>" % (
bg,
td("AS" + str(r["asn"]), "#64748b", "right", mono=True),
td((d.get("name") or "?")[:28], "#e2e8f0"),
td(d.get("rir", "?"), "#94a3b8", "center"),
td(d.get("cc", "?"), "#94a3b8", "center"),
td(str(d.get("pfx_total", 0)), "#94a3b8", "right", mono=True),
td(ripe, "#64748b", "right", mono=True),
td(str(pct) + "%", rpki_c, "right", mono=True),
td(str(d.get("ix_cnt", 0)), "#94a3b8", "right", mono=True),
td(pdb, "#64748b", "right", mono=True),
td(str(d.get("fac_cnt", 0)), "#94a3b8", "right", mono=True),
st_html)
all_table = (
"<h3 style='color:#94a3b8;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>All Tested ASNs (%d)</h3>" % len(sorted_results) +
"<div style='overflow-x:auto'><table style='width:100%%;border-collapse:collapse;border-radius:6px;overflow:hidden'>%s%s</table></div>" % (header, rows)
)
html = """<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<div style="max-width:900px;margin:0 auto;padding:24px 16px">
<div style="margin-bottom:20px">
<span style="color:#3b82f6;font-weight:700;font-size:18px;letter-spacing:1px">PEER</span><span style="color:#fff;font-weight:700;font-size:18px;letter-spacing:1px">CORTEX</span>
<span style="color:#475569;font-size:12px;margin-left:10px">Daily Audit Report &middot; %(date)s</span>
</div>
<div style="background:%(sc)s18;border:1px solid %(sc)s40;border-radius:8px;padding:14px 18px;margin-bottom:20px">
<div style="font-size:17px;font-weight:700;color:%(sc)s">%(emoji)s %(text)s</div>
<div style="color:#94a3b8;font-size:12px;margin-top:4px">%(total)d ASNs tested from pool of %(pool)d &middot; Runtime: %(mins)dm %(secs)ds &middot; Cross-validated: RIPE Stat + PeeringDB</div>
</div>
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px"><tr>
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
<div style="font-size:32px;font-weight:700;color:#22c55e">%(ok)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">PERFECT</div></td>
<td style="width:3%%"></td>
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
<div style="font-size:32px;font-weight:700;color:#f97316">%(warn)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">WARNINGS</div></td>
<td style="width:3%%"></td>
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
<div style="font-size:32px;font-weight:700;color:#ef4444">%(crit)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">CRITICAL</div></td>
<td style="width:3%%"></td>
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
<div style="font-size:32px;font-weight:700;color:#475569">%(skip)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">SKIPPED</div></td>
</tr></table>
%(issues)s %(skipped)s %(rpki)s %(alltable)s
<div style="margin-top:32px;padding-top:14px;border-top:1px solid #1e293b;color:#475569;font-size:11px">
PeerCortex &middot; <a href="https://peercortex.org" style="color:#3b82f6;text-decoration:none">peercortex.org</a>
&middot; 100 ASNs rotate daily from %(pool)d ASN pool &middot; Audit at 02:00 UTC, report at 06:00 UTC
</div>
</div></body></html>""" % dict(
date=date_str, sc=sc, emoji=emoji, text=text,
total=total, pool=pool, mins=mins, secs=secs,
ok=ok, warn=warn, crit=crit, skip=skip,
issues=issues_html, skipped=skipped_html,
rpki=rpki_html, alltable=all_table)
return html, subject
def send_email(subject, html_body):
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = MAIL_FROM
msg["To"] = MAIL_TO
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain="fichtmueller.org")
msg.attach(MIMEText("PeerCortex Daily Audit — please view in an HTML-capable email client.\n\nhttps://peercortex.org", "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as s:
s.sendmail(MAIL_FROM, [MAIL_TO], msg.as_bytes())
print("Sent '%s' -> %s" % (subject, MAIL_TO))
if __name__ == "__main__":
report, date_str = load_report()
html, subject = build(report, date_str)
print("Email size: %d bytes" % len(html.encode()))
send_email(subject, html)