feat: complete dashboard with ASPA, bgproutes.io, enhanced RPKI

- Full network intelligence dashboard (777-line HTML)
- ASPA Intelligence: provider detection, object generator, path analysis
- bgproutes.io integration: 3293 vantage points, RIB queries, ROV+ASPA status
- Enhanced RPKI: per-prefix validation, coverage percentage, expandable details
- Enhanced Compare: common upstreams, RPKI coverage comparison
- API endpoints: /api/lookup, /api/aspa, /api/bgproutes, /api/compare, /api/health
- All data sources queried in parallel for speed
- Tokyo Night dark theme, responsive, loading states
This commit is contained in:
Rene Fichtmueller 2026-03-26 10:23:44 +13:00
parent 035e8921ae
commit fc58394555
2 changed files with 1471 additions and 0 deletions

777
public/index.html Normal file
View File

@ -0,0 +1,777 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PeerCortex - Network Intelligence Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0f0f1a;--card:#1a1b26;--card-hover:#1f2030;--border:#2a2b3d;--border-light:#363750;
--purple:#bb9af7;--blue:#7aa2f7;--green:#9ece6a;--orange:#ff9e64;--red:#f7768e;
--cyan:#7dcfff;--yellow:#e0af68;--white:#c0caf5;--muted:#565f89;--dim:#414868;
--text:#c0caf5;--text-dim:#a9b1d6;
}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh}
a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(--cyan)}
.header{background:linear-gradient(180deg,#1a1b2e 0%,var(--bg) 100%);border-bottom:1px solid var(--border);padding:1.5rem 0}
.header-inner{max-width:1200px;margin:0 auto;padding:0 1.5rem;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem}
.logo{display:flex;align-items:center;gap:.75rem}
.logo svg{width:36px;height:36px}
.logo h1{font-size:1.4rem;font-weight:700;color:var(--purple);letter-spacing:-.02em}
.logo span{font-size:.75rem;color:var(--muted);font-weight:400}
.quick-links{display:flex;gap:.75rem;flex-wrap:wrap}
.quick-links a{font-size:.75rem;padding:.35rem .7rem;border:1px solid var(--border);border-radius:6px;color:var(--muted);transition:all .2s}
.quick-links a:hover{border-color:var(--blue);color:var(--blue)}
.search-section{max-width:1200px;margin:2rem auto;padding:0 1.5rem}
.search-box{display:flex;gap:.75rem;align-items:stretch}
.search-input{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.85rem 1.2rem;font-size:1rem;color:var(--text);font-family:inherit;outline:none;transition:border-color .2s}
.search-input:focus{border-color:var(--purple)}
.search-input::placeholder{color:var(--dim)}
.search-btn{background:linear-gradient(135deg,var(--purple),var(--blue));border:none;border-radius:10px;padding:.85rem 2rem;font-size:1rem;font-weight:600;color:#fff;cursor:pointer;font-family:inherit;transition:opacity .2s;white-space:nowrap}
.search-btn:hover{opacity:.9}
.search-btn:disabled{opacity:.5;cursor:not-allowed}
.dashboard{max-width:1200px;margin:0 auto;padding:0 1.5rem 3rem;display:grid;grid-template-columns:1fr 1fr;gap:1.25rem}
@media(max-width:768px){.dashboard{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.5rem;transition:border-color .2s}
.card:hover{border-color:var(--border-light)}
.card.full{grid-column:1/-1}
.card-title{font-size:.85rem;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--purple);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}
.card-title svg{width:18px;height:18px;opacity:.7}
.stat-row{display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1rem}
.stat{text-align:center}
.stat-val{font-size:1.8rem;font-weight:700;color:var(--green);line-height:1.2}
.stat-val.blue{color:var(--blue)}.stat-val.purple{color:var(--purple)}.stat-val.orange{color:var(--orange)}.stat-val.red{color:var(--red)}.stat-val.cyan{color:var(--cyan)}
.stat-label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.badge{display:inline-block;padding:.2rem .6rem;border-radius:5px;font-size:.7rem;font-weight:600;margin-right:.4rem;margin-bottom:.3rem}
.badge-purple{background:rgba(187,154,247,.15);color:var(--purple)}
.badge-blue{background:rgba(122,162,247,.15);color:var(--blue)}
.badge-green{background:rgba(158,206,106,.15);color:var(--green)}
.badge-orange{background:rgba(255,158,100,.15);color:var(--orange)}
.badge-red{background:rgba(247,118,142,.15);color:var(--red)}
.badge-cyan{background:rgba(125,207,255,.15);color:var(--cyan)}
.progress-wrap{height:8px;background:var(--border);border-radius:4px;overflow:hidden;margin:.5rem 0}
.progress-bar{height:100%;border-radius:4px;transition:width .5s ease}
.progress-bar.green{background:var(--green)}.progress-bar.red{background:var(--red)}.progress-bar.orange{background:var(--orange)}.progress-bar.blue{background:var(--blue)}
.progress-multi{display:flex;height:8px;border-radius:4px;overflow:hidden;margin:.5rem 0;background:var(--border)}
.progress-multi>div{height:100%;transition:width .5s ease}
.tbl{width:100%;border-collapse:collapse;font-size:.8rem}
.tbl th{text-align:left;padding:.5rem .6rem;color:var(--muted);font-weight:600;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
.tbl td{padding:.5rem .6rem;border-bottom:1px solid rgba(42,43,61,.5);color:var(--text-dim)}
.tbl tr:hover td{background:rgba(187,154,247,.03)}
.tbl .asn-link{color:var(--blue);cursor:pointer;font-weight:500}
.tbl .asn-link:hover{color:var(--cyan);text-decoration:underline}
.rpki-valid{color:var(--green)}.rpki-invalid{color:var(--red)}.rpki-unknown{color:var(--muted)}
.big-score{font-size:4rem;font-weight:800;line-height:1;margin:.5rem 0}
.big-score.high{color:var(--green)}.big-score.mid{color:var(--orange)}.big-score.low{color:var(--red)}
.ext-links{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem}
.ext-link{font-size:.75rem;padding:.3rem .65rem;border:1px solid var(--border);border-radius:6px;color:var(--text-dim);transition:all .2s}
.ext-link:hover{border-color:var(--blue);color:var(--blue)}
.net-name{font-size:1.6rem;font-weight:700;color:var(--white);margin-bottom:.25rem}
.net-aka{font-size:.9rem;color:var(--muted);margin-bottom:.75rem}
.compare-section{max-width:1200px;margin:0 auto;padding:0 1.5rem 1rem}
.compare-box{display:flex;gap:.75rem;align-items:stretch;flex-wrap:wrap}
.compare-input{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.65rem 1rem;font-size:.9rem;color:var(--text);font-family:inherit;outline:none;width:160px;transition:border-color .2s}
.compare-input:focus{border-color:var(--purple)}
.compare-btn{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.65rem 1.5rem;font-size:.85rem;font-weight:600;color:var(--purple);cursor:pointer;font-family:inherit;transition:all .2s}
.compare-btn:hover{border-color:var(--purple);background:rgba(187,154,247,.1)}
.expand-toggle{font-size:.75rem;color:var(--blue);cursor:pointer;margin-top:.5rem;display:inline-block}
.expand-toggle:hover{text-decoration:underline}
.expand-body{display:none;margin-top:.5rem}
.expand-body.open{display:block}
.skeleton{background:linear-gradient(90deg,var(--border) 25%,var(--border-light) 50%,var(--border) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:6px;height:1rem;margin:.4rem 0}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.skeleton.h2{height:2rem;width:60%}.skeleton.h3{height:1.2rem;width:40%}.skeleton.wide{width:100%}.skeleton.med{width:70%}
.meta-bar{text-align:center;font-size:.75rem;color:var(--dim);margin-top:1rem;padding:0 1.5rem}
.footer{text-align:center;padding:2rem 1.5rem;color:var(--dim);font-size:.75rem;border-top:1px solid var(--border);margin-top:2rem}
.footer a{color:var(--muted)}
.flag{font-size:1.2rem;margin-right:.3rem}
.hidden{display:none !important}
.scroll-wrap{max-height:300px;overflow-y:auto}
.scroll-wrap::-webkit-scrollbar{width:6px}
.scroll-wrap::-webkit-scrollbar-track{background:transparent}
.scroll-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.compare-results{max-width:1200px;margin:0 auto;padding:0 1.5rem 1rem}
/* ASPA specific */
.aspa-template{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:1rem;font-family:'Courier New',monospace;font-size:.75rem;color:var(--cyan);white-space:pre-wrap;word-break:break-all;position:relative;margin:.5rem 0}
.copy-btn{position:absolute;top:.5rem;right:.5rem;background:var(--card);border:1px solid var(--border);border-radius:6px;padding:.3rem .6rem;font-size:.7rem;color:var(--muted);cursor:pointer;transition:all .2s}
.copy-btn:hover{border-color:var(--purple);color:var(--purple)}
/* Status indicator */
.status-yes{color:var(--green);font-weight:600}
.status-no{color:var(--red);font-weight:600}
.status-unknown{color:var(--muted);font-weight:600}
/* Loading spinner for sections */
.section-loading{text-align:center;padding:1rem;color:var(--muted);font-size:.8rem}
.section-loading::before{content:'';display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .8s linear infinite;margin-right:.5rem;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-inner">
<div class="logo">
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="16" stroke="#bb9af7" stroke-width="2"/>
<circle cx="18" cy="10" r="3" fill="#bb9af7"/>
<circle cx="10" cy="24" r="3" fill="#7aa2f7"/>
<circle cx="26" cy="24" r="3" fill="#9ece6a"/>
<line x1="18" y1="13" x2="11" y2="22" stroke="#565f89" stroke-width="1.5"/>
<line x1="18" y1="13" x2="25" y2="22" stroke="#565f89" stroke-width="1.5"/>
<line x1="13" y1="24" x2="23" y2="24" stroke="#565f89" stroke-width="1.5"/>
</svg>
<div><h1>PeerCortex</h1><span>Network Intelligence Dashboard v0.2</span></div>
</div>
<nav class="quick-links">
<a href="https://github.com/peercortex/peercortex" target="_blank">GitHub</a>
<a href="https://www.peeringdb.com" target="_blank">PeeringDB</a>
<a href="https://stat.ripe.net" target="_blank">RIPE Stat</a>
<a href="https://bgp.he.net" target="_blank">bgp.he.net</a>
<a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a>
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a>
</nav>
</div>
</header>
<!-- Search -->
<section class="search-section">
<div class="search-box">
<input type="text" class="search-input" id="asnInput" placeholder="Enter ASN (e.g. 13335, 6939, 174)" value="" autofocus>
<button class="search-btn" id="searchBtn" onclick="doLookup()">Lookup</button>
</div>
</section>
<!-- Meta bar -->
<div class="meta-bar" id="metaBar"></div>
<!-- Dashboard -->
<div class="dashboard hidden" id="dashboard">
<!-- Network Overview -->
<div class="card full" id="overviewCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Network Overview
</div>
<div id="overviewContent"></div>
</div>
<!-- Prefixes -->
<div class="card" id="prefixCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M4 12h16M12 4v16"/></svg>
Announced Prefixes
</div>
<div id="prefixContent"></div>
</div>
<!-- RPKI Compliance -->
<div class="card" id="rpkiCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
RPKI Compliance
</div>
<div id="rpkiContent"></div>
</div>
<!-- ASPA Intelligence (NEW) -->
<div class="card full" id="aspaCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2v-4M9 21H5a2 2 0 0 1-2-2v-4"/></svg>
ASPA Status
</div>
<div id="aspaContent"><div class="section-loading">Loading ASPA data...</div></div>
</div>
<!-- bgproutes.io (NEW) -->
<div class="card full" id="bgroutesCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
bgproutes.io
</div>
<div id="bgroutesContent"><div class="section-loading">Loading bgproutes.io data...</div></div>
</div>
<!-- Neighbours -->
<div class="card full" id="neighbourCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
AS Neighbours
</div>
<div id="neighbourContent"></div>
</div>
<!-- IX Presence -->
<div class="card full" id="ixCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="1" width="22" height="22" rx="2"/><path d="M7 1v22M17 1v22M1 12h22M1 7h22M1 17h22"/></svg>
IX Presence
</div>
<div id="ixContent"></div>
</div>
<!-- Facilities -->
<div class="card" id="facCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M3 7v14M21 7v14M6 21V10M10 21V10M14 21V10M18 21V10M12 7l9-4H3l9 4z"/></svg>
Facilities
</div>
<div id="facContent"></div>
</div>
<!-- Quick Compare -->
<div class="card" id="compareCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
Quick Compare
</div>
<div id="compareContent">
<p style="font-size:.8rem;color:var(--muted);margin-bottom:.75rem">Compare IX, facility, upstream overlap and RPKI coverage with another network.</p>
<div class="compare-box">
<input type="text" class="compare-input" id="compareAsn" placeholder="Second ASN">
<button class="compare-btn" onclick="doCompare()">Compare</button>
</div>
<div id="compareResults" style="margin-top:1rem"></div>
</div>
</div>
</div>
<!-- Loading Skeleton -->
<div class="dashboard hidden" id="skeleton">
<div class="card full"><div class="card-title">Network Overview</div><div class="skeleton h2"></div><div class="skeleton h3"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
<div class="card"><div class="card-title">Announced Prefixes</div><div class="skeleton h2"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
<div class="card"><div class="card-title">RPKI Compliance</div><div class="skeleton h2"></div><div class="skeleton wide"></div></div>
<div class="card full"><div class="card-title">ASPA Status</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
<div class="card full"><div class="card-title">bgproutes.io</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
<div class="card full"><div class="card-title">AS Neighbours</div><div class="skeleton wide"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
<div class="card full"><div class="card-title">IX Presence</div><div class="skeleton wide"></div><div class="skeleton wide"></div></div>
<div class="card"><div class="card-title">Facilities</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
<div class="card"><div class="card-title">Quick Compare</div><div class="skeleton med"></div></div>
</div>
<!-- Footer -->
<footer class="footer">
PeerCortex v0.2.0 &mdash; Open Source &mdash; MIT License<br>
<a href="https://github.com/renefichtmueller/PaperCortex" target="_blank">PaperCortex</a> &middot;
<a href="https://github.com/peercortex/peercortex" target="_blank">PeerCortex GitHub</a>
</footer>
<script>
const $ = id => document.getElementById(id);
let currentAsn = null;
function countryFlag(code) {
if (!code || code.length !== 2) return '';
const c = code.toUpperCase();
return String.fromCodePoint(...[...c].map(ch => 0x1F1E6 + ch.charCodeAt(0) - 65));
}
function fmtSpeed(mbps) {
if (!mbps || mbps === 0) return '-';
if (mbps >= 1000) return (mbps / 1000) + ' Gbps';
return mbps + ' Mbps';
}
function rpkiIcon(status) {
if (status === 'valid') return '<span class="rpki-valid" title="RPKI Valid">&#10003;</span>';
if (status === 'invalid') return '<span class="rpki-invalid" title="RPKI Invalid">&#10007;</span>';
return '<span class="rpki-unknown" title="Not Found">?</span>';
}
function pct(n, total) {
if (!total) return 0;
return Math.round((n / total) * 100);
}
function asnLink(asn) {
return '<span class="asn-link" onclick="lookupAsn(' + asn + ')" title="Lookup AS' + asn + '">AS' + asn + '</span>';
}
function lookupAsn(asn) {
$('asnInput').value = asn;
doLookup();
}
function copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(function() {
var orig = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = orig; }, 1500);
});
}
async function doLookup() {
const raw = $('asnInput').value.trim().replace(/[^0-9]/g, '');
if (!raw) return;
currentAsn = raw;
$('searchBtn').disabled = true;
$('searchBtn').textContent = 'Loading...';
$('dashboard').classList.add('hidden');
$('skeleton').classList.remove('hidden');
$('metaBar').textContent = '';
try {
const resp = await fetch('/api/lookup?asn=' + raw);
const d = await resp.json();
if (d.error) {
$('skeleton').classList.add('hidden');
$('metaBar').textContent = 'Error: ' + d.error;
return;
}
renderDashboard(d);
$('skeleton').classList.add('hidden');
$('dashboard').classList.remove('hidden');
$('metaBar').textContent = 'Query: ' + d.meta.query + ' | Duration: ' + d.meta.duration_ms + 'ms | RPKI checked: ' + d.meta.rpki_prefixes_checked + '/' + d.meta.total_prefixes + ' prefixes | ' + d.meta.timestamp;
history.replaceState(null, '', '?asn=' + raw);
// Load ASPA and bgproutes.io data asynchronously
loadAspaData(raw);
loadBgroutesData(raw);
} catch (e) {
$('skeleton').classList.add('hidden');
$('metaBar').textContent = 'Error: ' + e.message;
} finally {
$('searchBtn').disabled = false;
$('searchBtn').textContent = 'Lookup';
}
}
async function loadAspaData(asn) {
$('aspaContent').innerHTML = '<div class="section-loading">Loading ASPA data...</div>';
try {
const resp = await fetch('/api/aspa?asn=' + asn);
const d = await resp.json();
if (d.error) {
$('aspaContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">ASPA check failed: ' + escHtml(d.error) + '</div>';
return;
}
renderAspa(d);
} catch (e) {
$('aspaContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">ASPA check failed: ' + escHtml(e.message) + '</div>';
}
}
async function loadBgroutesData(asn) {
$('bgroutesContent').innerHTML = '<div class="section-loading">Loading bgproutes.io data...</div>';
try {
const resp = await fetch('/api/bgproutes?asn=' + asn);
const d = await resp.json();
if (d.error) {
$('bgroutesContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">bgproutes.io query failed: ' + escHtml(d.error) + '</div>';
return;
}
renderBgroutes(d);
} catch (e) {
$('bgroutesContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">bgproutes.io query failed: ' + escHtml(e.message) + '</div>';
}
}
function renderAspa(d) {
let h = '';
// ASPA status
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">ASPA Object</div>';
if (d.aspa_object_exists) {
h += '<div class="status-yes">Found in RIPE DB</div>';
} else {
h += '<div class="status-no">Not Found</div>';
}
h += '</div>';
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Detected Providers</div>';
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + d.provider_count + '</div>';
h += '</div>';
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">BGP Paths Analyzed</div>';
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--cyan)">' + (d.path_analysis ? d.path_analysis.total_paths_seen : 0) + '</div>';
h += '</div>';
h += '</div>';
// Detected providers
if (d.detected_providers && d.detected_providers.length > 0) {
h += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.75rem 0 .4rem">Detected Upstream Providers</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:1rem">';
d.detected_providers.forEach(function(p) {
h += '<span class="badge badge-orange">' + asnLink(p.asn) + ' ' + escHtml(p.name) + '</span>';
});
h += '</div>';
}
// Recommended ASPA template
if (d.recommended_aspa) {
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.75rem 0 .4rem">Recommended ASPA Object</div>';
h += '<div class="aspa-template" id="aspaTemplate">' + escHtml(d.recommended_aspa);
h += '<button class="copy-btn" onclick="copyToClipboard(document.getElementById(\'aspaTemplate\').innerText, this)">Copy</button>';
h += '</div>';
}
// Sample path analysis
if (d.path_analysis && d.path_analysis.sample_paths && d.path_analysis.sample_paths.length > 0) {
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show sample BGP paths (' + d.path_analysis.sample_paths.length + ')</div>';
h += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>RRC</th><th>Prefix</th><th>AS Path</th><th>Provider</th></tr></thead><tbody>';
d.path_analysis.sample_paths.forEach(function(p) {
h += '<tr><td>' + escHtml(p.rrc || '') + '</td>';
h += '<td style="font-family:monospace;font-size:.75rem">' + escHtml(p.prefix || '') + '</td>';
h += '<td style="font-family:monospace;font-size:.7rem">' + escHtml(p.path || '') + '</td>';
h += '<td>' + (p.detected_provider ? '<span class="badge badge-green">' + escHtml(p.detected_provider) + '</span>' : '-') + '</td></tr>';
});
h += '</tbody></table></div></div>';
}
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Analyzed in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms</div>';
$('aspaContent').innerHTML = h;
}
function renderBgroutes(d) {
let h = '';
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
// Vantage points
if (d.vantage_points) {
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Vantage Points</div>';
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--green)">' + (d.vantage_points.count || 0) + '</div>';
h += '</div>';
}
// Route data status
if (d.routes) {
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Route Data</div>';
if (d.routes.status === 'unavailable') {
h += '<div style="font-size:.85rem;color:var(--orange)">' + escHtml(d.routes.message) + '</div>';
} else {
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + (d.routes.count || 0) + ' routes</div>';
}
h += '</div>';
}
h += '</div>';
// VP list
if (d.vantage_points && d.vantage_points.list && d.vantage_points.list.length > 0) {
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show vantage points (' + d.vantage_points.list.length + ')</div>';
h += '<div class="expand-body"><div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>ID</th><th>Name</th><th>Location</th><th>ASN</th></tr></thead><tbody>';
d.vantage_points.list.forEach(function(vp) {
h += '<tr><td>' + escHtml(vp.id || vp.vp_id || '') + '</td>';
h += '<td>' + escHtml(vp.name || vp.description || '') + '</td>';
h += '<td>' + escHtml(vp.location || vp.city || vp.country || '') + '</td>';
h += '<td>' + escHtml(vp.asn || vp.peer_asn || '') + '</td></tr>';
});
h += '</tbody></table></div></div>';
}
// Route samples
if (d.routes && d.routes.sample && d.routes.sample.length > 0) {
if (d.routes.vp_used) {
h += '<div style="font-size:.75rem;color:var(--muted);margin-bottom:.5rem">VP: ' + escHtml(d.routes.vp_used.org || '') + ' (' + escHtml(d.routes.vp_used.country || '') + ', ID ' + (d.routes.vp_used.id || '') + ')</div>';
}
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show route data (' + d.routes.count + ' routes, showing ' + d.routes.sample.length + ')</div>';
h += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>Prefix</th><th>AS Path</th><th>ROV</th><th>ASPA</th></tr></thead><tbody>';
d.routes.sample.forEach(function(r) {
var rovBadge = (r.rov_status || '').indexOf('valid') >= 0 ? 'badge-green' : (r.rov_status || '').indexOf('invalid') >= 0 ? 'badge-red' : 'badge-orange';
var aspaBadge = (r.aspa_status || '') === 'valid' ? 'badge-green' : (r.aspa_status || '') === 'invalid' ? 'badge-red' : 'badge-orange';
h += '<tr><td style="font-family:monospace;font-size:.7rem">' + escHtml(r.prefix || '') + '</td>';
h += '<td style="font-family:monospace;font-size:.65rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escAttr(r.as_path || '') + '">' + escHtml(r.as_path || '') + '</td>';
h += '<td><span class="badge ' + rovBadge + '">' + escHtml(r.rov_status || '?') + '</span></td>';
h += '<td><span class="badge ' + aspaBadge + '">' + escHtml(r.aspa_status || '?') + '</span></td></tr>';
});
h += '</tbody></table></div></div>';
}
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Queried in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms</div>';
$('bgroutesContent').innerHTML = h;
}
function renderDashboard(d) {
const n = d.network;
const p = d.prefixes;
const r = d.rpki;
const nb = d.neighbours;
const ix = d.ix_presence;
const fac = d.facilities;
// Overview
let ov = '<div class="net-name">' + escHtml(n.name) + ' <span style="color:var(--muted);font-size:1rem">AS' + n.asn + '</span></div>';
if (n.aka) ov += '<div class="net-aka">AKA: ' + escHtml(n.aka) + '</div>';
if (n.country) ov += '<span class="flag">' + countryFlag(n.country) + '</span>';
if (n.rir) ov += '<span class="badge badge-cyan">' + escHtml(n.rir) + '</span>';
if (n.type) ov += '<span class="badge badge-purple">' + escHtml(n.type) + '</span>';
if (n.policy) ov += '<span class="badge badge-blue">' + escHtml(n.policy) + '</span>';
if (n.ratio) ov += '<span class="badge badge-green">' + escHtml(n.ratio) + '</span>';
if (n.scope) ov += '<span class="badge badge-orange">' + escHtml(n.scope) + '</span>';
if (n.traffic) ov += '<span class="badge badge-cyan">' + escHtml(n.traffic) + '</span>';
if (n.website) ov += '<div style="margin-top:.5rem"><a href="' + escAttr(n.website) + '" target="_blank">' + escHtml(n.website) + '</a></div>';
if (n.notes) ov += '<div style="margin-top:.5rem;font-size:.8rem;color:var(--muted)">' + escHtml(n.notes) + '</div>';
ov += '<div class="ext-links">';
if (n.peeringdb_id) ov += '<a class="ext-link" href="https://www.peeringdb.com/net/' + n.peeringdb_id + '" target="_blank">PeeringDB</a>';
ov += '<a class="ext-link" href="https://bgp.he.net/AS' + n.asn + '" target="_blank">bgp.he.net</a>';
ov += '<a class="ext-link" href="https://stat.ripe.net/AS' + n.asn + '" target="_blank">RIPE Stat</a>';
ov += '<a class="ext-link" href="https://bgproutes.io/search/AS' + n.asn + '" target="_blank">bgproutes.io</a>';
if (n.looking_glass) ov += '<a class="ext-link" href="' + escAttr(n.looking_glass) + '" target="_blank">Looking Glass</a>';
if (n.route_server) ov += '<a class="ext-link" href="' + escAttr(n.route_server) + '" target="_blank">Route Server</a>';
ov += '</div>';
$('overviewContent').innerHTML = ov;
// Prefixes
let px = '<div class="stat-row">';
px += '<div class="stat"><div class="stat-val blue">' + p.total + '</div><div class="stat-label">Total</div></div>';
px += '<div class="stat"><div class="stat-val green">' + p.ipv4 + '</div><div class="stat-label">IPv4</div></div>';
px += '<div class="stat"><div class="stat-val purple">' + p.ipv6 + '</div><div class="stat-label">IPv6</div></div>';
px += '</div>';
const v4pct = pct(p.ipv4, p.total);
px += '<div style="font-size:.7rem;color:var(--muted)">IPv4 ' + v4pct + '% / IPv6 ' + (100 - v4pct) + '%</div>';
px += '<div class="progress-multi"><div style="width:' + v4pct + '%;background:var(--green)"></div><div style="width:' + (100 - v4pct) + '%;background:var(--purple)"></div></div>';
if (p.list && p.list.length > 0) {
px += '<div class="expand-toggle" onclick="toggleExpand(this)">Show all ' + p.list.length + ' prefixes</div>';
px += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>Prefix</th><th>RPKI</th></tr></thead><tbody>';
const rpkiMap = {};
(r.details || []).forEach(function(rd) { rpkiMap[rd.prefix] = rd.status; });
p.list.forEach(function(pfx) {
const st = rpkiMap[pfx] || 'not_checked';
px += '<tr><td style="font-family:monospace;font-size:.8rem">' + escHtml(pfx) + '</td><td>' + rpkiIcon(st) + '</td></tr>';
});
px += '</tbody></table></div></div>';
}
$('prefixContent').innerHTML = px;
// RPKI
let rk = '';
const scoreClass = r.coverage_percent >= 90 ? 'high' : r.coverage_percent >= 70 ? 'mid' : 'low';
rk += '<div class="big-score ' + scoreClass + '">' + r.coverage_percent + '%</div>';
rk += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.5rem">RPKI Coverage (' + r.checked + ' prefixes checked)</div>';
const vpct = pct(r.valid, r.checked);
const ipct = pct(r.invalid, r.checked);
const npct = 100 - vpct - ipct;
rk += '<div class="progress-multi">';
rk += '<div style="width:' + vpct + '%;background:var(--green)" title="Valid ' + vpct + '%"></div>';
rk += '<div style="width:' + ipct + '%;background:var(--red)" title="Invalid ' + ipct + '%"></div>';
rk += '<div style="width:' + npct + '%;background:var(--dim)" title="Not Found ' + npct + '%"></div>';
rk += '</div>';
rk += '<div style="display:flex;gap:1.5rem;margin-top:.5rem;font-size:.8rem">';
rk += '<div><span style="color:var(--green);font-weight:600">' + r.valid + '</span> <span style="color:var(--muted)">valid</span></div>';
rk += '<div><span style="color:var(--red);font-weight:600">' + r.invalid + '</span> <span style="color:var(--muted)">invalid</span></div>';
rk += '<div><span style="color:var(--dim);font-weight:600">' + r.not_found + '</span> <span style="color:var(--muted)">not found</span></div>';
rk += '</div>';
// Expandable per-prefix RPKI details
if (r.details && r.details.length > 0) {
rk += '<div class="expand-toggle" onclick="toggleExpand(this)">Show per-prefix RPKI details (' + r.details.length + ')</div>';
rk += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>Prefix</th><th>Status</th><th>ROAs</th></tr></thead><tbody>';
r.details.forEach(function(rd) {
var icon = rpkiIcon(rd.status);
var statusText = rd.status === 'valid' ? '<span style="color:var(--green)">Valid</span>' : rd.status === 'invalid' ? '<span style="color:var(--red)">Invalid</span>' : '<span style="color:var(--muted)">Not Found</span>';
rk += '<tr><td style="font-family:monospace;font-size:.75rem">' + escHtml(rd.prefix) + '</td>';
rk += '<td>' + icon + ' ' + statusText + '</td>';
rk += '<td>' + (rd.validating_roas || 0) + '</td></tr>';
});
rk += '</tbody></table></div></div>';
}
$('rpkiContent').innerHTML = rk;
// Neighbours
let ne = '<div class="stat-row">';
ne += '<div class="stat"><div class="stat-val">' + nb.total + '</div><div class="stat-label">Total</div></div>';
ne += '<div class="stat"><div class="stat-val orange">' + nb.upstream_count + '</div><div class="stat-label">Upstreams</div></div>';
ne += '<div class="stat"><div class="stat-val cyan">' + nb.peer_count + '</div><div class="stat-label">Peers</div></div>';
ne += '<div class="stat"><div class="stat-val blue">' + nb.downstream_count + '</div><div class="stat-label">Downstreams</div></div>';
ne += '</div>';
ne += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">';
ne += '<div><div style="font-size:.75rem;font-weight:600;color:var(--orange);margin-bottom:.4rem">Top Upstreams</div>';
ne += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>ASN</th><th>Name</th><th>Power</th></tr></thead><tbody>';
(nb.upstreams || []).slice(0, 10).forEach(function(u) {
ne += '<tr><td>' + asnLink(u.asn) + '</td><td>' + escHtml(u.name) + '</td><td>' + (u.power || '-') + '</td></tr>';
});
ne += '</tbody></table></div></div>';
ne += '<div><div style="font-size:.75rem;font-weight:600;color:var(--cyan);margin-bottom:.4rem">Top Peers</div>';
ne += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>ASN</th><th>Name</th><th>Power</th></tr></thead><tbody>';
(nb.peers || []).slice(0, 10).forEach(function(u) {
ne += '<tr><td>' + asnLink(u.asn) + '</td><td>' + escHtml(u.name) + '</td><td>' + (u.power || '-') + '</td></tr>';
});
ne += '</tbody></table></div></div>';
ne += '</div>';
$('neighbourContent').innerHTML = ne;
// IX Presence
let ixh = '<div class="stat-row">';
ixh += '<div class="stat"><div class="stat-val green">' + ix.total_connections + '</div><div class="stat-label">Connections</div></div>';
ixh += '<div class="stat"><div class="stat-val purple">' + ix.unique_ixps + '</div><div class="stat-label">Unique IXPs</div></div>';
ixh += '</div>';
if (ix.connections && ix.connections.length > 0) {
ixh += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>IX Name</th><th>Speed</th><th>IPv4</th><th>IPv6</th></tr></thead><tbody>';
ix.connections.forEach(function(c) {
const ixUrl = c.ix_id ? 'https://www.peeringdb.com/ix/' + c.ix_id : '#';
ixh += '<tr><td><a href="' + ixUrl + '" target="_blank">' + escHtml(c.ix_name) + '</a></td>';
ixh += '<td>' + fmtSpeed(c.speed_mbps) + '</td>';
ixh += '<td style="font-family:monospace;font-size:.75rem">' + (c.ipv4 || '-') + '</td>';
ixh += '<td style="font-family:monospace;font-size:.75rem">' + (c.ipv6 || '-') + '</td></tr>';
});
ixh += '</tbody></table></div>';
}
$('ixContent').innerHTML = ixh;
// Facilities
let fh = '<div class="stat-row"><div class="stat"><div class="stat-val blue">' + fac.total + '</div><div class="stat-label">Facilities</div></div></div>';
if (fac.list && fac.list.length > 0) {
fh += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>Facility</th><th>City</th><th>Country</th></tr></thead><tbody>';
fac.list.forEach(function(f) {
const fUrl = f.fac_id ? 'https://www.peeringdb.com/fac/' + f.fac_id : '#';
fh += '<tr><td><a href="' + fUrl + '" target="_blank">' + escHtml(f.name) + '</a></td>';
fh += '<td>' + escHtml(f.city) + '</td>';
fh += '<td>' + countryFlag(f.country) + ' ' + escHtml(f.country) + '</td></tr>';
});
fh += '</tbody></table></div>';
}
$('facContent').innerHTML = fh;
}
async function doCompare() {
if (!currentAsn) return;
const raw2 = $('compareAsn').value.trim().replace(/[^0-9]/g, '');
if (!raw2) return;
$('compareResults').innerHTML = '<div class="skeleton wide"></div><div class="skeleton med"></div>';
try {
const resp = await fetch('/api/compare?asn1=' + currentAsn + '&asn2=' + raw2);
const d = await resp.json();
let h = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">';
h += '<div style="text-align:center"><div class="stat-val blue">' + escHtml(d.asn1.name) + '</div><div class="stat-label">AS' + d.asn1.asn + ' (' + d.asn1.ix_count + ' IXPs)</div></div>';
h += '<div style="text-align:center"><div class="stat-val purple">' + escHtml(d.asn2.name) + '</div><div class="stat-label">AS' + d.asn2.asn + ' (' + d.asn2.ix_count + ' IXPs)</div></div>';
h += '</div>';
// RPKI comparison
if (d.rpki_comparison) {
h += '<div style="font-size:.8rem;font-weight:600;color:var(--purple);margin:.75rem 0 .4rem">RPKI Coverage Comparison</div>';
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:.75rem">';
var s1class = d.rpki_comparison.asn1_coverage >= 90 ? 'green' : d.rpki_comparison.asn1_coverage >= 70 ? 'orange' : 'red';
var s2class = d.rpki_comparison.asn2_coverage >= 90 ? 'green' : d.rpki_comparison.asn2_coverage >= 70 ? 'orange' : 'red';
h += '<div style="text-align:center"><div style="font-size:2rem;font-weight:700;color:var(--' + s1class + ')">' + d.rpki_comparison.asn1_coverage + '%</div><div style="font-size:.7rem;color:var(--muted)">AS' + d.asn1.asn + ' (' + d.rpki_comparison.asn1_checked + ' checked)</div></div>';
h += '<div style="text-align:center"><div style="font-size:2rem;font-weight:700;color:var(--' + s2class + ')">' + d.rpki_comparison.asn2_coverage + '%</div><div style="font-size:.7rem;color:var(--muted)">AS' + d.asn2.asn + ' (' + d.rpki_comparison.asn2_checked + ' checked)</div></div>';
h += '</div>';
if (d.rpki_comparison.better !== 'equal') {
h += '<div style="text-align:center;font-size:.8rem;color:var(--green);margin-bottom:.5rem">' + escHtml(d.rpki_comparison.better) + ' has better RPKI coverage</div>';
}
}
// Common upstreams
if (d.common_upstreams && d.common_upstreams.length > 0) {
h += '<div style="font-size:.8rem;font-weight:600;color:var(--yellow);margin:.5rem 0">Common Upstreams (' + d.common_upstreams.length + ')</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.5rem">';
d.common_upstreams.forEach(function(u) { h += '<span class="badge badge-orange">' + asnLink(u.asn) + ' ' + escHtml(u.name) + '</span>'; });
h += '</div>';
} else if (d.common_upstreams) {
h += '<div style="font-size:.8rem;color:var(--muted);margin:.5rem 0">No common upstreams detected</div>';
}
h += '<div style="font-size:.8rem;font-weight:600;color:var(--green);margin:.5rem 0">Common IXPs (' + d.common_ixps.length + ')</div>';
if (d.common_ixps.length > 0) {
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
d.common_ixps.forEach(function(ix) { h += '<span class="badge badge-green">' + escHtml(ix.name) + '</span>'; });
h += '</div>';
} else {
h += '<div style="font-size:.8rem;color:var(--muted)">No common IXPs</div>';
}
h += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.5rem 0">Only AS' + d.asn1.asn + ' (' + d.only_asn1_ixps.length + ')</div>';
if (d.only_asn1_ixps.length > 0) {
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
d.only_asn1_ixps.slice(0, 15).forEach(function(ix) { h += '<span class="badge badge-orange">' + escHtml(ix.name) + '</span>'; });
if (d.only_asn1_ixps.length > 15) h += '<span class="badge badge-orange">+' + (d.only_asn1_ixps.length - 15) + ' more</span>';
h += '</div>';
}
h += '<div style="font-size:.8rem;font-weight:600;color:var(--blue);margin:.5rem 0">Only AS' + d.asn2.asn + ' (' + d.only_asn2_ixps.length + ')</div>';
if (d.only_asn2_ixps.length > 0) {
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
d.only_asn2_ixps.slice(0, 15).forEach(function(ix) { h += '<span class="badge badge-blue">' + escHtml(ix.name) + '</span>'; });
if (d.only_asn2_ixps.length > 15) h += '<span class="badge badge-blue">+' + (d.only_asn2_ixps.length - 15) + ' more</span>';
h += '</div>';
}
if (d.common_facilities && d.common_facilities.length > 0) {
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.5rem 0">Common Facilities (' + d.common_facilities.length + ')</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
d.common_facilities.forEach(function(f) { h += '<span class="badge badge-cyan">' + escHtml(f.name) + '</span>'; });
h += '</div>';
}
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Compared in ' + d.meta.duration_ms + 'ms</div>';
$('compareResults').innerHTML = h;
} catch (e) {
$('compareResults').innerHTML = '<div style="color:var(--red);font-size:.8rem">Compare failed: ' + escHtml(e.message) + '</div>';
}
}
function toggleExpand(el) {
const body = el.nextElementSibling;
const isOpen = body.classList.contains('open');
body.classList.toggle('open');
el.textContent = isOpen ? el.textContent.replace('Hide', 'Show') : el.textContent.replace('Show', 'Hide');
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escAttr(s) {
return escHtml(s);
}
$('asnInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doLookup();
});
(function() {
const params = new URLSearchParams(window.location.search);
const asn = params.get('asn');
if (asn) {
$('asnInput').value = asn;
doLookup();
}
})();
</script>
</body>
</html>

694
server.js Normal file
View File

@ -0,0 +1,694 @@
const fs = require("fs");
const http = require("http");
const https = require("https");
// Load .env file
const envPath = "/opt/peercortex-app/.env";
try {
const envContent = fs.readFileSync(envPath, "utf8");
envContent.split("\n").forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.substring(0, eqIdx).trim();
const val = trimmed.substring(eqIdx + 1).trim();
if (!process.env[key]) process.env[key] = val;
}
});
} catch (_e) {
console.warn("Warning: Could not read .env file at", envPath);
}
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
const UA = "PeerCortex/0.2.0 (https://github.com/renefichtmueller/PeerCortex)";
function fetchJSON(url, options) {
return new Promise((resolve) => {
const reqOptions = {
headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) },
};
https
.get(url, reqOptions, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (_e) {
resolve(null);
}
});
})
.on("error", () => resolve(null));
});
}
function postJSON(url, body, options) {
return new Promise((resolve) => {
const data = JSON.stringify(body);
const parsed = new URL(url);
const reqOptions = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method: "POST",
headers: {
"User-Agent": UA,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data),
...(options && options.headers ? options.headers : {}),
},
};
const req = https.request(reqOptions, (res) => {
let chunks = "";
res.on("data", (chunk) => (chunks += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(chunks));
} catch (_e) {
resolve(null);
}
});
});
req.on("error", () => resolve(null));
req.write(data);
req.end();
});
}
function fetchRPKIPerPrefix(asn, prefix) {
return fetchJSON(
"https://stat.ripe.net/data/rpki-validation/data.json?resource=AS" +
asn +
"&prefix=" +
encodeURIComponent(prefix)
).then((r) => {
const status = r?.data?.status || "not_found";
const validating = r?.data?.validating_roas || [];
return { prefix, status, validating_roas: validating.length };
});
}
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(204);
return res.end();
}
const url = new URL(req.url, "http://localhost");
const reqPath = url.pathname;
// Serve static files
if (reqPath === "/" || reqPath === "/index.html") {
try {
const html = fs.readFileSync("/opt/peercortex-app/public/index.html", "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(html);
} catch (_e) {
res.writeHead(500);
return res.end("index.html not found");
}
}
// Serve favicon
if (reqPath === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
res.setHeader("Content-Type", "application/json");
// Health endpoint
if (reqPath === "/api/health") {
return res.end(
JSON.stringify({
status: "ok",
service: "PeerCortex",
version: "0.2.0",
timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()),
bgproutes_configured: !!BGPROUTES_API_KEY,
})
);
}
// ============================================================
// ASPA Check endpoint: /api/aspa?asn=X
// ============================================================
if (reqPath === "/api/aspa") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const start = Date.now();
try {
const [lgData, neighbourData] = await Promise.all([
fetchJSON("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn),
fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn),
]);
// Extract AS paths from looking glass
const rrcs = lgData?.data?.rrcs || [];
const asPaths = [];
const upstreamSet = new Set();
rrcs.forEach((rrc) => {
const peers = rrc.peers || [];
peers.forEach((peer) => {
const path = peer.as_path || "";
const pathArr = path.split(" ").map(Number).filter(Boolean);
if (pathArr.length > 1) {
asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
const idx = pathArr.indexOf(parseInt(rawAsn));
if (idx > 0) {
upstreamSet.add(pathArr[idx - 1]);
}
}
});
});
// Also get upstreams from neighbour data
const neighbours = neighbourData?.data?.neighbours || [];
const leftNeighbours = neighbours.filter((n) => n.type === "left");
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
const detectedProviders = [...upstreamSet].map((asn) => {
const nb = leftNeighbours.find((n) => n.asn === asn);
return { asn, name: nb ? nb.as_name || "AS" + asn : "AS" + asn };
});
// Check RIPE DB for ASPA references
let aspaObjectExists = false;
try {
const ripeDbInfo = await fetchJSON(
"https://rest.db.ripe.net/search.json?query-string=AS" +
rawAsn +
"&type-filter=aut-num&source=ripe"
);
const objects = ripeDbInfo?.objects?.object || [];
objects.forEach((obj) => {
const attrs = obj.attributes?.attribute || [];
attrs.forEach((attr) => {
if (attr.name === "remarks" && attr.value && attr.value.toLowerCase().includes("aspa")) {
aspaObjectExists = true;
}
});
});
} catch (_e) {
// RIPE DB query failed, continue
}
// Generate recommended ASPA object template
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
const recommendedAspa =
"aut-num: AS" + rawAsn + "\n" +
"# Recommended ASPA object:\n" +
"# customer: AS" + rawAsn + "\n" +
"# provider-set: " + providerList + "\n" +
"# AFI: ipv4, ipv6\n" +
"#\n" +
"# Detected providers from BGP path analysis:\n" +
detectedProviders.map((p) => "# AS" + p.asn + " (" + p.name + ")").join("\n");
// Sample path analysis
const samplePaths = asPaths.slice(0, 10).map((p) => {
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
const idx = p.path.indexOf(parseInt(rawAsn));
const provider = idx > 0 ? p.path[idx - 1] : null;
return {
rrc: p.rrc,
prefix: p.prefix,
path: pathStr,
detected_provider: provider ? "AS" + provider : null,
provider_in_set: provider ? upstreamSet.has(provider) : false,
};
});
const duration = Date.now() - start;
return res.end(
JSON.stringify(
{
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
asn: parseInt(rawAsn),
detected_providers: detectedProviders,
provider_count: detectedProviders.length,
aspa_object_exists: aspaObjectExists,
recommended_aspa: recommendedAspa,
path_analysis: {
total_paths_seen: asPaths.length,
sample_paths: samplePaths,
},
},
null,
2
)
);
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
}
}
// ============================================================
// bgproutes.io endpoint: /api/bgproutes?asn=X (or prefix=X)
// ============================================================
if (reqPath === "/api/bgproutes") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
const prefix = url.searchParams.get("prefix") || "";
if (!rawAsn && !prefix) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
}
const start = Date.now();
try {
const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null };
// Fetch vantage points
const vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", {
headers: { "x-api-key": BGPROUTES_API_KEY },
});
if (vpData && !vpData.error) {
const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []);
const readyVPs = Array.isArray(vpList) ? vpList.filter((vp) => !vp.status || (Array.isArray(vp.status) && vp.status.includes("ready"))) : [];
result.vantage_points = {
count: readyVPs.length,
total: Array.isArray(vpList) ? vpList.length : 0,
list: readyVPs.slice(0, 20).map((vp) => ({
id: vp.id,
asn: vp.asn,
ip: vp.ip,
source: vp.source || "",
org_name: vp.org_name || "",
country: vp.org_country || vp.country || "",
rib_v4: vp.rib_size_v4 || 0,
rib_v6: vp.rib_size_v6 || 0,
})),
};
} else {
result.vantage_points = { count: 0, error: "Could not fetch vantage points" };
}
// RIB query via POST - pick a ready VP with good RIB size
let ribSuccess = false;
const readyVPsForRib = result.vantage_points && result.vantage_points.list
? result.vantage_points.list.filter((vp) => vp.rib_v4 > 500000).slice(0, 1)
: [];
if (readyVPsForRib.length > 0) {
const vpId = readyVPsForRib[0].id;
const now = new Date().toISOString().replace(/\.\d+Z$/, "");
const ribBody = {
vp_bgp_ids: String(vpId),
date: now,
return_aspath: true,
return_rov_status: true,
return_aspa_status: true,
};
if (prefix) {
ribBody.prefix_exact_match = prefix;
} else if (rawAsn) {
ribBody.aspath_regexp = rawAsn + "$";
}
try {
const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, {
headers: { "x-api-key": BGPROUTES_API_KEY },
});
if (ribData && ribData.data) {
const bgpData = ribData.data.bgp || {};
const vpRoutes = bgpData[String(vpId)] || {};
const routeEntries = Object.entries(vpRoutes).map(([pfx, arr]) => {
// arr format: [as_path, communities, rov_status, aspa_status, ...]
const asPath = Array.isArray(arr) ? arr[0] || "" : "";
const rovStatus = Array.isArray(arr) ? arr[2] || "" : "";
const aspaStatus = Array.isArray(arr) ? arr[3] || "" : "";
return {
prefix: pfx,
as_path: asPath,
rov_status: rovStatus.split(",").map((s) => s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s).join(","),
aspa_status: aspaStatus.split(",").map((s) => s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s).join(","),
};
});
if (routeEntries.length > 0) {
result.routes = {
count: routeEntries.length,
vp_used: { id: vpId, org: readyVPsForRib[0].org_name, country: readyVPsForRib[0].country },
sample: routeEntries.slice(0, 20),
};
ribSuccess = true;
}
}
} catch (_e) { /* RIB POST query failed */ }
}
if (!ribSuccess) {
result.routes = {
status: "unavailable",
message: readyVPsForRib.length === 0
? "No ready VPs with sufficient RIB size found"
: "bgproutes.io: VPs available but RIB query returned no data for this ASN",
};
}
result.meta.duration_ms = Date.now() - start;
return res.end(JSON.stringify(result, null, 2));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "bgproutes.io query failed", message: err.message }));
}
}
// ============================================================
// Main lookup endpoint: /api/lookup?asn=X
// ============================================================
if (reqPath === "/api/lookup") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
}
const asn = rawAsn;
const start = Date.now();
try {
const [pdbNet, prefixData, neighbourData, overviewData, rirData] = await Promise.all([
fetchJSON("https://www.peeringdb.com/api/net?asn=" + asn),
fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn),
fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
fetchJSON("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
fetchJSON("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn),
]);
const net = pdbNet?.data?.[0] || {};
const netId = net.id;
const prefixes = prefixData?.data?.prefixes || [];
const neighbours = neighbourData?.data?.neighbours || [];
const overview = overviewData?.data || {};
const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
// Phase 2: IX + Facilities + RPKI (batched 20 at a time)
const phase2Promises = [];
if (netId) {
phase2Promises.push(fetchJSON("https://www.peeringdb.com/api/netixlan?net_id=" + netId));
phase2Promises.push(fetchJSON("https://www.peeringdb.com/api/netfac?net_id=" + netId));
} else {
phase2Promises.push(Promise.resolve(null));
phase2Promises.push(Promise.resolve(null));
}
// RPKI batched 20 at a time, up to 50 prefixes
const allPrefixes = prefixes.map((p) => p.prefix);
const rpkiAllResults = [];
const batchSize = 20;
for (let i = 0; i < Math.min(allPrefixes.length, 50); i += batchSize) {
const batch = allPrefixes.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map((pfx) => fetchRPKIPerPrefix(asn, pfx)));
rpkiAllResults.push(...batchResults);
}
const [ixlanData, facData] = await Promise.all(phase2Promises);
const ixConnections = (ixlanData?.data || [])
.map((ix) => ({
ix_name: ix.name || "",
ix_id: ix.ix_id,
speed_mbps: ix.speed || 0,
ipv4: ix.ipaddr4 || null,
ipv6: ix.ipaddr6 || null,
city: ix.city || "",
}))
.sort((a, b) => b.speed_mbps - a.speed_mbps);
const facilities = (facData?.data || []).map((f) => ({
fac_id: f.fac_id,
name: f.name || "",
city: f.city || "",
country: f.country || "",
}));
const rpkiStatuses = rpkiAllResults;
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length;
const rpkiTotal = rpkiStatuses.length;
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
const upstreams = neighbours
.filter((n) => n.type === "left")
.map((n) => ({ asn: n.asn, name: n.as_name || "AS" + n.asn, power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
const downstreams = neighbours
.filter((n) => n.type === "right")
.map((n) => ({ asn: n.asn, name: n.as_name || "AS" + n.asn, power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
const peers = neighbours
.filter((n) => n.type === "uncertain" || n.type === "peer")
.map((n) => ({ asn: n.asn, name: n.as_name || "AS" + n.asn, power: n.power || 0 }))
.sort((a, b) => b.power - a.power);
let rir = "";
let country = "";
if (Array.isArray(rirEntries) && rirEntries.length > 0) {
rir = rirEntries[0]?.rir || "";
country = rirEntries[0]?.country || "";
}
if (!rir && rirData?.data) {
const rirField = rirData.data.rirs || [];
if (rirField.length > 0) rir = rirField[0]?.rir || "";
}
const duration = Date.now() - start;
const result = {
meta: {
service: "PeerCortex",
version: "0.2.0",
query: "AS" + asn,
duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat"],
timestamp: new Date().toISOString(),
rpki_prefixes_checked: rpkiTotal,
total_prefixes: prefixes.length,
},
network: {
asn: parseInt(asn),
name: net.name || overview?.holder || "Unknown",
aka: net.aka || "",
website: net.website || "",
type: net.info_type || "",
policy: net.policy_general || "",
traffic: net.info_traffic || "",
ratio: net.info_ratio || "",
scope: net.info_scope || "",
notes: net.notes ? net.notes.substring(0, 500) : "",
peeringdb_id: netId || null,
rir: rir,
country: country,
looking_glass: net.looking_glass || "",
route_server: net.route_server || "",
},
prefixes: {
total: prefixes.length,
ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
list: prefixes.map((p) => p.prefix),
},
rpki: {
coverage_percent: rpkiCoverage,
valid: rpkiValid,
invalid: rpkiInvalid,
not_found: rpkiNotFound,
checked: rpkiTotal,
details: rpkiStatuses,
},
neighbours: {
total: neighbours.length,
upstream_count: upstreams.length,
downstream_count: downstreams.length,
peer_count: peers.length,
upstreams: upstreams.slice(0, 20),
downstreams: downstreams.slice(0, 20),
peers: peers.slice(0, 20),
},
ix_presence: {
total_connections: ixConnections.length,
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
connections: ixConnections,
},
facilities: {
total: facilities.length,
list: facilities,
},
};
res.end(JSON.stringify(result, null, 2));
} catch (err) {
const duration = Date.now() - start;
res.writeHead(500);
res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
}
return;
}
// ============================================================
// Compare endpoint: /api/compare?asn1=X&asn2=Y (enhanced)
// ============================================================
if (reqPath === "/api/compare") {
const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
if (!asn1 || !asn2) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
}
const start = Date.now();
try {
const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
fetchJSON("https://www.peeringdb.com/api/net?asn=" + asn1),
fetchJSON("https://www.peeringdb.com/api/net?asn=" + asn2),
fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1),
fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2),
fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1),
fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2),
]);
const net1 = pdb1?.data?.[0] || {};
const net2 = pdb2?.data?.[0] || {};
const ixFacPromises = [];
if (net1.id) {
ixFacPromises.push(fetchJSON("https://www.peeringdb.com/api/netixlan?net_id=" + net1.id));
ixFacPromises.push(fetchJSON("https://www.peeringdb.com/api/netfac?net_id=" + net1.id));
} else {
ixFacPromises.push(Promise.resolve(null));
ixFacPromises.push(Promise.resolve(null));
}
if (net2.id) {
ixFacPromises.push(fetchJSON("https://www.peeringdb.com/api/netixlan?net_id=" + net2.id));
ixFacPromises.push(fetchJSON("https://www.peeringdb.com/api/netfac?net_id=" + net2.id));
} else {
ixFacPromises.push(Promise.resolve(null));
ixFacPromises.push(Promise.resolve(null));
}
const [ix1Data, fac1Data, ix2Data, fac2Data] = await Promise.all(ixFacPromises);
const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
const ix1Names = {};
(ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
const ix2Names = {};
(ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
const fac1Names = {};
(fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
const fac2Names = {};
(fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
// Common upstreams
const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
const up1Set = new Set(nb1.map((n) => n.asn));
const up2Set = new Set(nb2.map((n) => n.asn));
const nb1Map = {};
nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || "AS" + n.asn));
const nb2Map = {};
nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || "AS" + n.asn));
const commonUpstreams = [...up1Set]
.filter((a) => up2Set.has(a))
.map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "AS" + a }));
// RPKI comparison (sample 10 prefixes each)
const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 10).map((p) => p.prefix);
const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 10).map((p) => p.prefix);
const [rpki1Results, rpki2Results] = await Promise.all([
Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))),
Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))),
]);
const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
const duration = Date.now() - start;
res.end(
JSON.stringify(
{
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
asn1: {
asn: parseInt(asn1),
name: net1.name || "Unknown",
ix_count: ix1Set.size,
fac_count: fac1Set.size,
upstream_count: up1Set.size,
rpki_coverage: rpki1Pct,
},
asn2: {
asn: parseInt(asn2),
name: net2.name || "Unknown",
ix_count: ix2Set.size,
fac_count: fac2Set.size,
upstream_count: up2Set.size,
rpki_coverage: rpki2Pct,
},
common_ixps: commonIX,
only_asn1_ixps: only1IX,
only_asn2_ixps: only2IX,
common_facilities: commonFac,
common_upstreams: commonUpstreams,
rpki_comparison: {
asn1_coverage: rpki1Pct,
asn2_coverage: rpki2Pct,
asn1_checked: rpki1Results.length,
asn2_checked: rpki2Results.length,
better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
},
},
null,
2
)
);
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
}
return;
}
// 404
res.writeHead(404);
res.end(
JSON.stringify({
error: "Not found. Endpoints: /api/health, /api/lookup?asn=X, /api/aspa?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y",
})
);
});
const PORT = process.env.PORT || 3101;
server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.2.0 running on http://0.0.0.0:" + PORT);
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
});