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.
227 lines
12 KiB
Python
Executable File
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 (<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 · %(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 · Runtime: %(mins)dm %(secs)ds · 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 · <a href="https://peercortex.org" style="color:#3b82f6;text-decoration:none">peercortex.org</a>
|
|
· 100 ASNs rotate daily from %(pool)d ASN pool · 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)
|