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.
271 lines
12 KiB
Python
271 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
PeerCortex Rotating Daily Audit
|
|
- Pool of 500+ ASNs
|
|
- Each day: deterministic 100-ASN selection (seeded by date → reproducible, never same day twice)
|
|
- Each ASN: PeerCortex response cross-validated against RIPE Stat + PeeringDB externally
|
|
- Math checks: prefix sums, RPKI sums, IX dedup, facility counts
|
|
- Results saved to /var/log/peercortex/audit-YYYY-MM-DD.json
|
|
"""
|
|
import json, urllib.request, urllib.error, time, sys, os
|
|
from datetime import datetime, timezone
|
|
from hashlib import sha256
|
|
|
|
# ── ASN Pool (500+ diverse networks across all RIRs) ──────────────────────────
|
|
ASN_POOL = [
|
|
# Tier-1 / Large Transit
|
|
1, 174, 209, 286, 701, 702, 1239, 1273, 1280, 1299, 2914, 3257, 3320,
|
|
3356, 3491, 3549, 3561, 4134, 4637, 4657, 5511, 6453, 6461, 6762, 6830,
|
|
7018, 7473, 7922, 8220, 8928, 9002, 9304, 12956, 13030,
|
|
# Cloud / Hyperscaler
|
|
714, 2906, 8075, 13335, 15169, 16509, 16591, 20940, 21342, 32934, 36351,
|
|
54113, 136907, 14061, 19604, 63949, 22616,
|
|
# European ISPs
|
|
1257, 1853, 2119, 2603, 3209, 3215, 3216, 3233, 3243, 3269, 3292, 3301,
|
|
3303, 3308, 3327, 5089, 5390, 5432, 5483, 6057, 6128, 6147, 6695, 6728,
|
|
6830, 6939, 8220, 8359, 8400, 8422, 8447, 8468, 8473, 8551, 8708, 9121,
|
|
9145, 9158, 12714, 13237, 15435, 15547, 15600, 15692, 15830, 16276,
|
|
20562, 20932, 21202, 24940, 24961, 25091, 28716, 29049, 29208, 33891,
|
|
34465, 34549, 35369, 39122, 41327, 42, 42739, 43350, 47692, 48159,
|
|
50629, 51088, 58057, 197540, 199121,
|
|
# APNIC
|
|
2497, 2516, 4766, 7545, 9318, 9498, 17676, 18403, 23969, 24115, 38001,
|
|
45899, 55836, 132335, 136907, 137409, 139070, 4713, 9269, 17488, 23944,
|
|
38197, 45352, 63927, 131090, 133481, 134762, 137718, 149440,
|
|
# ARIN
|
|
701, 1239, 1280, 2828, 3549, 3561, 6128, 6939, 7018, 7922, 10429, 10796,
|
|
11427, 13977, 14080, 15133, 19281, 20115, 20325, 21928, 22773, 22822,
|
|
23106, 32934, 33070, 35911, 36351, 40191, 43996, 54113, 62240, 64271,
|
|
393398, 11404, 14618, 14593, 7046, 26101, 10310, 32590,
|
|
# LACNIC
|
|
6147, 8167, 10429, 10474, 11419, 11644, 14080, 18881, 22927, 23106,
|
|
28573, 52320, 53013, 61832, 262589, 263702,
|
|
# AFRINIC
|
|
10474, 12091, 23889, 24835, 30844, 36864, 36903, 37100, 37271, 37468,
|
|
55330, 327814,
|
|
# IXPs / Route Servers
|
|
6695, 24115, 34307, 42476, 43366, 47498, 56393,
|
|
# CDN / Hosting
|
|
2906, 8100, 13335, 15133, 16509, 20940, 22822, 24429, 46489, 54113,
|
|
60068, 60501, 62240, 64271, 132335,
|
|
# Security / DNS
|
|
19281, 15169, 13335, 714, 20473,
|
|
# Mixed / Small
|
|
29791, 34224, 35332, 39386, 41695, 42473, 44574, 47065, 48858, 50300,
|
|
56630, 57695, 58057, 59947, 60068, 60501, 63023, 64271, 132335,
|
|
# More European
|
|
1200, 2611, 3265, 5388, 5430, 5444, 6128, 8309, 8365, 8560, 8607,
|
|
9009, 12897, 13285, 15516, 16150, 20738, 21263, 24586, 24875, 25369,
|
|
28917, 29017, 30036, 30781, 31025, 31400, 33986, 34006, 34224, 34655,
|
|
35432, 39386, 39737, 41327, 41552, 42652, 43531, 44574, 47065,
|
|
# Telcos worldwide
|
|
2516, 3320, 3786, 4766, 4800, 5483, 7473, 8151, 8708, 9121, 9304,
|
|
9318, 9498, 10429, 12479, 12714, 15704, 16276, 17676, 18403, 22773,
|
|
]
|
|
# Deduplicate while preserving order
|
|
seen = set()
|
|
ASN_POOL_DEDUP = [x for x in ASN_POOL if not (x in seen or seen.add(x))]
|
|
|
|
def load_pool():
|
|
"""Load ASN pool from file, fall back to built-in list."""
|
|
pool_file = os.path.join(os.path.dirname(__file__), 'asn_pool.json')
|
|
try:
|
|
with open(pool_file) as f:
|
|
return json.load(f)
|
|
except:
|
|
return ASN_POOL_DEDUP
|
|
|
|
def select_daily_asns(date_str, count=100):
|
|
"""Deterministic daily selection — same date always gives same 100 ASNs."""
|
|
import random
|
|
pool = load_pool()
|
|
seed = int(sha256(date_str.encode()).hexdigest(), 16)
|
|
rng = random.Random(seed)
|
|
rng.shuffle(pool)
|
|
return sorted(pool[:count])
|
|
|
|
def fetch(url, timeout=12):
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "PeerCortex-Audit/5.0"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return json.loads(r.read())
|
|
except:
|
|
return None
|
|
|
|
def fetch_pc(asn, timeout=20):
|
|
try:
|
|
with urllib.request.urlopen(f"http://localhost:3101/api/lookup?asn={asn}", timeout=timeout) as r:
|
|
return json.loads(r.read())
|
|
except:
|
|
return None
|
|
|
|
def audit_asn(asn):
|
|
issues = []
|
|
result = {"asn": asn, "status": "ok", "issues": [], "data": {}}
|
|
|
|
# ── 1. PeerCortex response ───────────────────────────────────────────────
|
|
d = fetch_pc(asn)
|
|
if not d or "network" not in d or d.get("error"):
|
|
result["status"] = "skip"
|
|
result["issues"].append(("SKIP", "no_response", "PeerCortex did not respond"))
|
|
return result
|
|
|
|
n = d.get("network", {})
|
|
p = d.get("prefixes", {})
|
|
r = d.get("rpki", {})
|
|
ix = d.get("ix_presence", {})
|
|
fac = d.get("facilities", {})
|
|
meta= d.get("meta", {})
|
|
|
|
result["data"] = {
|
|
"name": n.get("name"),
|
|
"rir": n.get("rir"),
|
|
"cc": n.get("country"),
|
|
"pfx_total": p.get("total", 0),
|
|
"rpki_pct": r.get("coverage_percent", 0),
|
|
"ix_cnt": ix.get("total_connections", 0),
|
|
"fac_cnt": fac.get("total", 0),
|
|
"version": meta.get("version"),
|
|
}
|
|
|
|
# ── 2. Math checks (internal consistency) ───────────────────────────────
|
|
if p.get("ipv4", 0) + p.get("ipv6", 0) != p.get("total", -1):
|
|
issues.append(("CRITICAL", "prefix_math",
|
|
f"v4({p.get('ipv4')})+v6({p.get('ipv6')}) ≠ total({p.get('total')})"))
|
|
|
|
rpki_sum = r.get("valid", 0) + r.get("invalid", 0) + r.get("not_found", 0)
|
|
if rpki_sum != r.get("checked", 0):
|
|
issues.append(("CRITICAL", "rpki_sum",
|
|
f"{rpki_sum} ≠ checked({r.get('checked')})"))
|
|
|
|
if r.get("checked", 0) > 0:
|
|
exp = round(r.get("valid", 0) / r.get("checked") * 100)
|
|
if abs(exp - r.get("coverage_percent", 0)) > 1:
|
|
issues.append(("CRITICAL", "rpki_pct",
|
|
f"expected {exp}% got {r.get('coverage_percent')}%"))
|
|
|
|
conns = ix.get("connections", [])
|
|
dedup = len(set(c.get("ix_id") for c in conns if c.get("ix_id")))
|
|
if dedup != ix.get("unique_ixps", 0):
|
|
issues.append(("CRITICAL", "ix_dedup",
|
|
f"dedup={dedup} ≠ unique_ixps={ix.get('unique_ixps')}"))
|
|
|
|
if len(conns) != ix.get("total_connections", 0):
|
|
issues.append(("WARNING", "ix_total",
|
|
f"len={len(conns)} ≠ total_connections={ix.get('total_connections')}"))
|
|
|
|
if len(fac.get("list", [])) != fac.get("total", 0):
|
|
issues.append(("WARNING", "fac_total",
|
|
f"len={len(fac.get('list',[]))} ≠ total={fac.get('total')}"))
|
|
|
|
# ── 3. Mandatory fields ──────────────────────────────────────────────────
|
|
if not n.get("name") or n.get("name") == "Unknown":
|
|
issues.append(("CRITICAL", "name_empty", f"name={n.get('name')!r}"))
|
|
|
|
if not n.get("asn"):
|
|
issues.append(("CRITICAL", "asn_field", "ASN field missing in response"))
|
|
|
|
if not n.get("rir"):
|
|
issues.append(("WARNING", "rir_empty", f"rir empty, country={n.get('country')!r}"))
|
|
|
|
if not n.get("country"):
|
|
issues.append(("WARNING", "country_empty", "country field empty"))
|
|
|
|
# ── 4. External cross-validation: RIPE Stat prefix count ────────────────
|
|
ripe = fetch(f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}")
|
|
if ripe and ripe.get("data", {}).get("prefixes") is not None:
|
|
ripe_pfx = len(ripe["data"]["prefixes"])
|
|
pc_pfx = p.get("total", 0)
|
|
result["data"]["ripe_stat_pfx"] = ripe_pfx
|
|
if ripe_pfx > 0 and pc_pfx > 0:
|
|
ratio = min(ripe_pfx, pc_pfx) / max(ripe_pfx, pc_pfx)
|
|
if ratio < 0.85:
|
|
issues.append(("WARNING", "prefix_ext_mismatch",
|
|
f"PC={pc_pfx} vs RIPE Stat={ripe_pfx} ({round((1-ratio)*100)}% diff)"))
|
|
elif ripe_pfx == 0 and pc_pfx > 0:
|
|
issues.append(("WARNING", "prefix_ext_zero",
|
|
f"RIPE Stat reports 0 prefixes, PC shows {pc_pfx}"))
|
|
|
|
# ── 5. External cross-validation: PeeringDB IX count ────────────────────
|
|
pdb = fetch(f"https://www.peeringdb.com/api/netixlan?asn={asn}&depth=0")
|
|
if pdb and isinstance(pdb.get("data"), list):
|
|
pdb_ix = len(pdb["data"])
|
|
pc_ix = ix.get("total_connections", 0)
|
|
result["data"]["pdb_ix_cnt"] = pdb_ix
|
|
if pdb_ix > 0 and pc_ix > 0:
|
|
ratio = min(pdb_ix, pc_ix) / max(pdb_ix, pc_ix)
|
|
if ratio < 0.85:
|
|
issues.append(("WARNING", "ix_ext_mismatch",
|
|
f"PC={pc_ix} vs PeeringDB={pdb_ix} ({round((1-ratio)*100)}% diff)"))
|
|
|
|
# ── Categorize ───────────────────────────────────────────────────────────
|
|
result["issues"] = issues
|
|
crits = [x for x in issues if x[0] == "CRITICAL"]
|
|
warns = [x for x in issues if x[0] == "WARNING"]
|
|
if crits:
|
|
result["status"] = "critical"
|
|
elif warns:
|
|
result["status"] = "warning"
|
|
else:
|
|
result["status"] = "ok"
|
|
|
|
return result
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
if __name__ == "__main__":
|
|
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
asns = select_daily_asns(date_str, 100)
|
|
start = time.time()
|
|
|
|
print(f"PeerCortex Rotating Audit — {date_str}")
|
|
print(f"Pool: {len(ASN_POOL_DEDUP)} ASNs | Today: {len(asns)} selected")
|
|
print("=" * 70)
|
|
|
|
results = []
|
|
counts = {"ok": 0, "warning": 0, "critical": 0, "skip": 0}
|
|
|
|
for i, asn in enumerate(asns):
|
|
sys.stdout.write(f"\r [{i+1:3d}/100] AS{asn}... ")
|
|
sys.stdout.flush()
|
|
r = audit_asn(asn)
|
|
results.append(r)
|
|
counts[r["status"]] += 1
|
|
time.sleep(0.3) # be gentle to external APIs
|
|
|
|
elapsed = time.time() - start
|
|
print(f"\r Done in {elapsed:.0f}s" + " " * 30)
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────────
|
|
print()
|
|
print("=" * 70)
|
|
print(f"SUMMARY — {date_str}")
|
|
print("=" * 70)
|
|
print(f" ✓ PERFECT: {counts['ok']:3d}")
|
|
print(f" ⚠ WARNING: {counts['warning']:3d}")
|
|
print(f" ✗ CRITICAL: {counts['critical']:3d}")
|
|
print(f" ⏭ SKIP: {counts['skip']:3d}")
|
|
|
|
issues_found = [r for r in results if r["status"] in ("critical", "warning")]
|
|
if issues_found:
|
|
print(f"\n ISSUES ({len(issues_found)} ASNs):")
|
|
for r in issues_found:
|
|
for sev, field, msg in r["issues"]:
|
|
print(f" [{sev[:4]}] AS{r['asn']:>7} | {field:30s} | {msg[:55]}")
|
|
|
|
# ── Save JSON report ──────────────────────────────────────────────────────
|
|
report = {
|
|
"date": date_str,
|
|
"elapsed_s": round(elapsed),
|
|
"asns_tested": asns,
|
|
"pool_size": len(ASN_POOL_DEDUP),
|
|
"counts": counts,
|
|
"results": results,
|
|
"generated": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
out_path = f"/var/log/peercortex/audit-{date_str}.json"
|
|
os.makedirs("/var/log/peercortex", exist_ok=True)
|
|
with open(out_path, "w") as f:
|
|
json.dump(report, f, indent=2)
|
|
print(f"\nReport: {out_path}")
|
|
print(f"Audit complete: {datetime.now(timezone.utc).isoformat()}")
|