fix: eliminate hanging cards — ASPA/bgproutes/WHOIS/PeeringRec all responsive

- ASPA: 15min result cache + looking-glass timeout 3s (was 8s), hard cap 12s (was 18s)
- bgproutes: 15min result cache + 6s timeout on RIB POST (was no timeout), vpCache 1h
- WHOIS: 24h cache + RDAP fallback timeouts 3s (was 5s)
- Peering Recommendations: replace 20x full /api/lookup with new /api/quick-ix
- postJSON: add configurable timeout (was no timeout, caused indefinite hangs)
- Frontend: AbortController timeouts on all slow card fetches (ASPA/bgproutes/WHOIS/health)
- New /api/quick-ix endpoint: PeeringDB IX data + network name, 1h cached
This commit is contained in:
Rene Fichtmueller 2026-04-08 23:56:08 +02:00
parent 344ee15338
commit 35b89c05aa
5 changed files with 2228 additions and 172 deletions

View File

@ -4,6 +4,32 @@ All notable changes to PeerCortex are documented here.
--- ---
## v0.6.9 — 2026-04-05
### Added
- **Resilience Score**: Weighted 4-factor score (110) per ASN — Transit Diversity (30%), Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%). Hard cap at 5.0 when only a single transit provider is detected. Shows a large score digit plus four colour-coded progress bars in the UI.
- **Route Leak Detection**: Heuristic analysis using RIPE Stat neighbour data. Detects two patterns: *sandwich candidates* (Tier-1 appearing as both upstream and downstream) and *Tier-1 as downstream* (unusual re-origination). Reference set: 21 known Tier-1 ASNs. Confidence: medium — pattern-based, not real-time.
- **Data Provenance System**: Every API response field carries `_provenance` metadata — source, validation method (cross-validated / heuristic / computed / single-source), confidence (high / medium / experimental), and an optional note. Shown in the UI as coloured badges next to each card title.
- **MCP Server** (`mcp-server.js`): PeerCortex as MCP tools for Claude Desktop and Claude Code — `lookup_asn`, `compare_networks`, `get_health_report`, `search_network`, `get_resilience_score`.
- **Rotating Daily Audit**: 100 ASNs tested daily, deterministically rotated via SHA256 date seed. Math checks (prefix sums, RPKI sums, IX dedup) + external cross-validation against RIPE Stat and PeeringDB.
- **Daily Audit Email**: HTML report with all tested ASNs, cross-validation columns and critical/warning/ok/skip counts, sent daily at 06:00 UTC.
### Fixed
- **ASN name fallback**: ASNs with no RIPE Stat holder or RDAP data now resolve name and country from `bgp.he.net` page title and country href — eliminates `Unknown` name entries for unassigned blocks and micro-ISPs.
---
## v0.6.8 — 2026-04-03
### Fixed
- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block).
- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB).
### Infrastructure
- Daily automated audit introduced: 103 ASNs validated every 24h.
---
## v0.6.6 — 2026-04-02 ## v0.6.6 — 2026-04-02
### Added ### Added
@ -15,6 +41,12 @@ All notable changes to PeerCortex are documented here.
--- ---
## v0.6.5 — 2026-04-02
### Added
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.
---
## v0.6.4 — 2026-04-02 ## v0.6.4 — 2026-04-02
@ -113,47 +145,3 @@ All notable changes to PeerCortex are documented here.
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation | | [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation |
| [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases | | [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases |
| [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion | | [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion |
## v0.6.5 — 2026-04-02
### Added
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.
## [0.6.8] — 2026-04-03
### Fixed
- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder
now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block)
- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country
now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB)
### Quality Audit — 2026-04-03 (103 ASNs, dual-run validation)
- **0 CRITICAL data errors** across all 103 audited ASNs
- **97 PERFECT** — 94% with zero issues
- **6 WARNING only** — slow cold-cache on large Tier-1 carriers and minor
source disagreement in external registries (not PeerCortex data errors)
- All mathematical consistency checks passed 103/103:
prefix math · RPKI math · RPKI coverage% · IX dedup · facility counts
- Prefix counts cross-validated against RIPE Stat: no deviation >10%
- IX connections cross-validated against PeeringDB: no deviation >10%
### Infrastructure
- Daily automated audit introduced: 103 ASNs validated every 24h
## [0.6.9] — 2026-04-04
### Added
- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%),
Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%).
Hard cap at 5.0 when single transit provider detected.
Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
- **Route Leak Detection**: Heuristic pattern detection for suspicious routing
relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM —
pattern-based, not real-time. False positives possible.
- **Data Provenance System**: Every data point in the API response now includes
a _provenance field: source, validation method (cross-validated / heuristic /
computed / single-source), and confidence level (high / medium / experimental).
Visible in UI as colour-coded badges: green = validated, orange = indicative.
- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude
Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report,
search_network, get_resilience_score. All responses include provenance metadata.

View File

@ -9,3 +9,10 @@
{"d":"2026-03-30","t":"FIX","m":"enrich: skip Wikipedia disambiguation pages, try first-word fallback for compound names"} {"d":"2026-03-30","t":"FIX","m":"enrich: skip Wikipedia disambiguation pages, try first-word fallback for compound names"}
{"d":"2026-03-30","t":"INFRA","m":"reisekosten.context-x.org DNS CNAME configured + service live on port 3104"} {"d":"2026-03-30","t":"INFRA","m":"reisekosten.context-x.org DNS CNAME configured + service live on port 3104"}
{"d":"2026-03-30","t":"INFRA","m":"PeerCortex repo created and pushed to Gitea (gitea.context-x.org/rene/PeerCortex)"} {"d":"2026-03-30","t":"INFRA","m":"PeerCortex repo created and pushed to Gitea (gitea.context-x.org/rene/PeerCortex)"}
{"d":"2026-04-08","t":"FIX","m":"MANRS check: replace failing Observatory API (auth required) with public participants page scraping (manrs.org/netops/participants/), 24h cache, O(1) Set lookup"}
{"d":"2026-04-08","t":"FIX","m":"IX Route Servers: identified PeeringDB auth/rate-limit as root cause for excluded status — API key verification on Erik pending"}
{"d":"2026-04-08","t":"FEAT","m":"Prefix Changes card: 5 tabs (Announcements, Withdrawals, Origin Changes, RPKI Issues, Live Stream) with custom time range picker — data via RIPE Stat bgp-updates + local ROA store + RIPE RIS Live WebSocket"}
{"d":"2026-04-08","t":"FIX","m":"WHOIS: add 24h module-level cache, reduce RDAP fallback timeouts 5s→3s — eliminates repeated hammering and hangs for non-RIPE ASNs"}
{"d":"2026-04-08","t":"FIX","m":"Peering Recommendations: replace 20 concurrent full lookup calls with new /api/quick-ix endpoint — was hanging indefinitely on every new lookup"}
{"d":"2026-04-08","t":"FIX","m":"ASPA: reduce looking-glass timeout 8s→5s and hard cap 18s→12s — faster response for slow RIPE Stat endpoints"}
{"d":"2026-04-08","t":"FEAT","m":"New /api/quick-ix endpoint: lightweight PeeringDB IX connections + network name, 1h cache"}

File diff suppressed because it is too large Load Diff

View File

@ -733,6 +733,36 @@ body.dark .card{border-top-color:#e8e4dc}
<div id="hijackContent"></div> <div id="hijackContent"></div>
</section> </section>
<!-- Prefix Changes -->
<section class="card hidden" id="pfxChangesCard" title="Prefix Changes — BGP announcements, withdrawals, origin-ASN changes, RPKI status issues, and live RIS stream for a custom time window">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Prefix Changes
</div>
<!-- Time range picker -->
<div id="pfxTimeRange" style="display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin-bottom:.75rem">
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-right:.2rem">RANGE:</span>
<button class="pfx-preset active" onclick="pfxSetPreset(1)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">1h</button>
<button class="pfx-preset" onclick="pfxSetPreset(6)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">6h</button>
<button class="pfx-preset" onclick="pfxSetPreset(24)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">24h</button>
<button class="pfx-preset" onclick="pfxSetPreset(168)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">7d</button>
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-left:.4rem">CUSTOM:</span>
<input type="datetime-local" id="pfxFrom" style="font-family:var(--mono);font-size:.65rem;background:transparent;border:1px solid var(--border);color:var(--text);padding:.2rem .4rem;outline:none">
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim)"></span>
<input type="datetime-local" id="pfxTo" style="font-family:var(--mono);font-size:.65rem;background:transparent;border:1px solid var(--border);color:var(--text);padding:.2rem .4rem;outline:none">
<button onclick="pfxLoadCustom()" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .7rem;background:var(--text);color:var(--bg);border:none;cursor:pointer;letter-spacing:.05em">LOAD</button>
</div>
<!-- Tabs -->
<div style="display:flex;gap:0;margin-bottom:.75rem;border-bottom:1px solid var(--border)">
<button class="pfx-tab active" onclick="pfxTab('ann')" id="pfxTabAnn" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid var(--text);background:transparent;color:var(--text);cursor:pointer">📢 Announced <span id="pfxCntAnn" style="color:var(--dim)"></span></button>
<button class="pfx-tab" onclick="pfxTab('wd')" id="pfxTabWd" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">📤 Withdrawn <span id="pfxCntWd" style="color:var(--dim)"></span></button>
<button class="pfx-tab" onclick="pfxTab('orig')" id="pfxTabOrig" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🔄 Origin Changes <span id="pfxCntOrig" style="color:var(--dim)"></span></button>
<button class="pfx-tab" onclick="pfxTab('rpki')" id="pfxTabRpki" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🛡 RPKI Issues <span id="pfxCntRpki" style="color:var(--dim)"></span></button>
<button class="pfx-tab" onclick="pfxTab('live')" id="pfxTabLive" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🔴 Live</button>
</div>
<div id="pfxContent" style="font-family:var(--mono);font-size:.75rem"></div>
</section>
<!-- AS-SET Expander --> <!-- AS-SET Expander -->
<section class="card hidden" id="assetCard" title="AS-SET expander — recursively resolves an IRR AS-SET (e.g. AS-EXAMPLE) to the full list of member ASNs. Useful for validating import/export filters in router configs"> <section class="card hidden" id="assetCard" title="AS-SET expander — recursively resolves an IRR AS-SET (e.g. AS-EXAMPLE) to the full list of member ASNs. Useful for validating import/export filters in router configs">
<div class="card-title"> <div class="card-title">
@ -1180,8 +1210,11 @@ async function doLookup() {
async function loadAspaData(asn) { async function loadAspaData(asn) {
$('aspaContent').innerHTML = '<div class="section-loading">Loading ASPA data...</div>'; $('aspaContent').innerHTML = '<div class="section-loading">Loading ASPA data...</div>';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try { try {
const resp = await fetch('/api/aspa?asn=' + asn); const resp = await fetch('/api/aspa?asn=' + asn, { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) { $('aspaContent').textContent = 'ASPA data unavailable (server ' + resp.status + ')'; renderProviderGraphFromLookupFallback(asn); return; } if (!resp.ok) { $('aspaContent').textContent = 'ASPA data unavailable (server ' + resp.status + ')'; renderProviderGraphFromLookupFallback(asn); return; }
var text = await resp.text(); var text = await resp.text();
if (!text || text[0] === '<') { $('aspaContent').textContent = 'ASPA data unavailable (timeout). Provider data shown from lookup.'; renderProviderGraphFromLookupFallback(asn); return; } if (!text || text[0] === '<') { $('aspaContent').textContent = 'ASPA data unavailable (timeout). Provider data shown from lookup.'; renderProviderGraphFromLookupFallback(asn); return; }
@ -1189,7 +1222,8 @@ async function loadAspaData(asn) {
if (d.error) { $('aspaContent').textContent = 'ASPA check failed: ' + d.error; renderProviderGraphFromLookupFallback(asn); return; } if (d.error) { $('aspaContent').textContent = 'ASPA check failed: ' + d.error; renderProviderGraphFromLookupFallback(asn); return; }
renderAspa(d); renderAspa(d);
} catch (e) { } catch (e) {
$('aspaContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">ASPA check failed: ' + escHtml(e.message) + '</div>'; clearTimeout(timer);
$('aspaContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">ASPA data temporarily unavailable</div>';
renderProviderGraphFromLookupFallback(asn); renderProviderGraphFromLookupFallback(asn);
} }
} }
@ -1206,16 +1240,20 @@ function renderProviderGraphFromLookupFallback(asn) {
async function loadBgroutesData(asn) { async function loadBgroutesData(asn) {
$('bgroutesContent').innerHTML = '<div class="section-loading">Loading bgproutes.io data...</div>'; $('bgroutesContent').innerHTML = '<div class="section-loading">Loading bgproutes.io data...</div>';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 12000);
try { try {
const resp = await fetch('/api/bgproutes?asn=' + asn); const resp = await fetch('/api/bgproutes?asn=' + asn, { signal: ctrl.signal });
clearTimeout(timer);
const d = await resp.json(); const d = await resp.json();
if (d.error) { if (d.error) {
$('bgroutesContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">bgproutes.io query failed: ' + escHtml(d.error) + '</div>'; $('bgroutesContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">bgproutes.io data temporarily unavailable</div>';
return; return;
} }
renderBgroutes(d); renderBgroutes(d);
} catch (e) { } catch (e) {
$('bgroutesContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">bgproutes.io query failed: ' + escHtml(e.message) + '</div>'; clearTimeout(timer);
$('bgroutesContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">bgproutes.io data temporarily unavailable</div>';
} }
} }
@ -2036,8 +2074,11 @@ function escAttr(s) {
async function loadAspaVerifyData(asn) { async function loadAspaVerifyData(asn) {
$('aspaDeepContent').innerHTML = '<div class="section-loading">Running RFC-compliant ASPA verification...</div>'; $('aspaDeepContent').innerHTML = '<div class="section-loading">Running RFC-compliant ASPA verification...</div>';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000);
try { try {
const resp = await fetch('/api/aspa/verify?asn=' + asn); const resp = await fetch('/api/aspa/verify?asn=' + asn, { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) { $('aspaDeepContent').textContent = 'ASPA verification unavailable (server ' + resp.status + ')'; return; } if (!resp.ok) { $('aspaDeepContent').textContent = 'ASPA verification unavailable (server ' + resp.status + ')'; return; }
var text = await resp.text(); var text = await resp.text();
if (!text || text[0] === '<') { $('aspaDeepContent').textContent = 'ASPA verification unavailable (timeout for large ASNs)'; return; } if (!text || text[0] === '<') { $('aspaDeepContent').textContent = 'ASPA verification unavailable (timeout for large ASNs)'; return; }
@ -2045,7 +2086,8 @@ async function loadAspaVerifyData(asn) {
if (d.error) { $('aspaDeepContent').textContent = 'ASPA verification failed: ' + d.error; return; } if (d.error) { $('aspaDeepContent').textContent = 'ASPA verification failed: ' + d.error; return; }
renderAspaDeep(d); renderAspaDeep(d);
} catch (e) { } catch (e) {
$('aspaDeepContent').textContent = 'ASPA verification failed: ' + e.message; clearTimeout(timer);
$('aspaDeepContent').textContent = 'ASPA verification temporarily unavailable';
} }
} }
@ -2807,8 +2849,11 @@ function renderIxTrafficStats(ixConnections) {
// ============================================================ // ============================================================
async function loadWhoisData(asn) { async function loadWhoisData(asn) {
$('whoisContent').innerHTML = '<div class="section-loading">Loading WHOIS data...</div>'; $('whoisContent').innerHTML = '<div class="section-loading">Loading WHOIS data...</div>';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10000);
try { try {
var resp = await fetch('/api/whois?resource=AS' + asn); var resp = await fetch('/api/whois?resource=AS' + asn, { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS unavailable (server ' + resp.status + ')</div>'; return; } if (!resp.ok) { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS unavailable (server ' + resp.status + ')</div>'; return; }
var text = await resp.text(); var text = await resp.text();
if (!text || text[0] === '<') { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>'; return; } if (!text || text[0] === '<') { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>'; return; }
@ -2816,7 +2861,8 @@ async function loadWhoisData(asn) {
if (d.error) { $('whoisContent').innerHTML = '<div style="color:var(--orange);font-size:.85rem">WHOIS: ' + escHtml(d.error) + '</div>'; return; } if (d.error) { $('whoisContent').innerHTML = '<div style="color:var(--orange);font-size:.85rem">WHOIS: ' + escHtml(d.error) + '</div>'; return; }
renderWhois(d); renderWhois(d);
} catch (e) { } catch (e) {
$('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS lookup failed: ' + escHtml(e.message) + '</div>'; clearTimeout(timer);
$('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>';
} }
} }
@ -2907,8 +2953,11 @@ async function loadOverviewEnrichment(asn, name, website) {
async function loadHealthReport(asn) { async function loadHealthReport(asn) {
$('healthContent').innerHTML = '<div class="section-loading">Running comprehensive validation (13 checks)...</div>'; $('healthContent').innerHTML = '<div class="section-loading">Running comprehensive validation (13 checks)...</div>';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 20000);
try { try {
var resp = await fetch('/api/validate?asn=' + asn); var resp = await fetch('/api/validate?asn=' + asn, { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report unavailable (server ' + resp.status + ')</div>'; return; } if (!resp.ok) { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report unavailable (server ' + resp.status + ')</div>'; return; }
var text = await resp.text(); var text = await resp.text();
if (!text || text[0] === '<') { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>'; return; } if (!text || text[0] === '<') { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>'; return; }
@ -2919,6 +2968,7 @@ async function loadHealthReport(asn) {
} }
renderHealthReport(d); renderHealthReport(d);
} catch (e) { } catch (e) {
clearTimeout(timer);
$('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>'; $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>';
} }
} }
@ -3277,11 +3327,11 @@ function loadPeeringRecommendations(asn, ixConnections, lookupData) {
$('peeringRecContent').innerHTML = '<div style="color:var(--dim);font-size:.85rem">Checking peering potential with top 20 networks...</div>'; $('peeringRecContent').innerHTML = '<div style="color:var(--dim);font-size:.85rem">Checking peering potential with top 20 networks...</div>';
// Fetch IX presence for top networks // Fetch IX presence for top networks via lightweight quick-ix endpoint (1h cached)
Promise.all(topNets.map(function(targetAsn) { Promise.all(topNets.map(function(targetAsn) {
return fetch('/api/lookup?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) { return fetch('/api/quick-ix?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
var name = d.network ? d.network.name : 'AS' + targetAsn; var name = d.name || ('AS' + targetAsn);
var theirIx = (d.ix_presence && d.ix_presence.connections) || []; var theirIx = d.ix_connections || [];
var theirIxIds = new Set(theirIx.map(function(ix) { return ix.ix_id; })); var theirIxIds = new Set(theirIx.map(function(ix) { return ix.ix_id; }));
var common = []; var common = [];
myIxIds.forEach(function(id) { if (theirIxIds.has(id)) common.push(myIxNames[id] || 'IX-' + id); }); myIxIds.forEach(function(id) { if (theirIxIds.has(id)) common.push(myIxNames[id] || 'IX-' + id); });
@ -3903,6 +3953,13 @@ function loadNewFeatures(asn) {
loadRpkiHistory(asn); loadRpkiHistory(asn);
loadAspath(asn); loadAspath(asn);
loadHijackMonitor(asn); loadHijackMonitor(asn);
// init time range to last 1h on first load, then load prefix changes
if (!document.getElementById('pfxFrom').value) {
const to = new Date(); const from = new Date(Date.now() - 3600000);
document.getElementById('pfxFrom').value = from.toISOString().slice(0,16);
document.getElementById('pfxTo').value = to.toISOString().slice(0,16);
}
pfxLoad(asn);
// IXP picker: read from ix_presence.connections (the actual API response structure) // IXP picker: read from ix_presence.connections (the actual API response structure)
setTimeout(() => { setTimeout(() => {
const raw = currentLookupData || {}; const raw = currentLookupData || {};
@ -3925,6 +3982,172 @@ fetch('/api/visitors').then(r=>r.json()).then(d=>{
}).catch(()=>{}); }).catch(()=>{});
// ── Prefix Changes ─────────────────────────────────────────────
let pfxCurrentAsn = null;
let pfxCurrentData = null;
let pfxActiveTab = 'ann';
let pfxLiveWs = null;
let pfxLiveLines = [];
function pfxSetPreset(hours) {
document.querySelectorAll('.pfx-preset').forEach(b => b.style.borderColor = 'var(--border)');
event.target.style.borderColor = 'var(--text)';
const to = new Date();
const from = new Date(Date.now() - hours * 3600000);
document.getElementById('pfxFrom').value = from.toISOString().slice(0,16);
document.getElementById('pfxTo').value = to.toISOString().slice(0,16);
if (pfxCurrentAsn) pfxLoad(pfxCurrentAsn);
}
function pfxLoadCustom() {
document.querySelectorAll('.pfx-preset').forEach(b => b.style.borderColor = 'var(--border)');
if (pfxCurrentAsn) pfxLoad(pfxCurrentAsn);
}
async function pfxLoad(asn) {
pfxCurrentAsn = asn;
document.getElementById('pfxChangesCard').classList.remove('hidden');
const el = document.getElementById('pfxContent');
el.textContent = 'Loading…';
el.style.color = 'var(--dim)';
const from = document.getElementById('pfxFrom').value;
const to = document.getElementById('pfxTo').value;
let url = '/api/prefix-changes?asn=' + encodeURIComponent(asn);
if (from && to) url += '&from=' + encodeURIComponent(new Date(from).toISOString()) + '&to=' + encodeURIComponent(new Date(to).toISOString());
try {
const resp = await fetch(url);
const d = await resp.json();
pfxCurrentData = d;
el.style.color = '';
document.getElementById('pfxCntAnn').textContent = d.summary ? '(' + d.summary.announcements + ')' : '';
document.getElementById('pfxCntWd').textContent = d.summary ? '(' + d.summary.withdrawals + ')' : '';
document.getElementById('pfxCntOrig').textContent = d.summary ? '(' + d.summary.origin_changes + ')' : '';
document.getElementById('pfxCntRpki').textContent = d.summary ? '(' + d.summary.rpki_issues + ')' : '';
pfxRender();
} catch(e) {
el.textContent = 'Error: ' + e.message;
el.style.color = 'var(--red)';
}
}
function pfxTab(name) {
pfxActiveTab = name;
document.querySelectorAll('.pfx-tab').forEach(b => { b.style.color = 'var(--dim)'; b.style.borderBottomColor = 'transparent'; });
const key = 'pfxTab' + name.charAt(0).toUpperCase() + name.slice(1);
const btn = document.getElementById(key);
if (btn) { btn.style.color = 'var(--text)'; btn.style.borderBottomColor = 'var(--text)'; }
if (name === 'live') { pfxRenderLive(pfxCurrentAsn); return; }
if (pfxLiveWs) { pfxLiveWs.close(); pfxLiveWs = null; }
pfxRender();
}
function pfxRender() {
const el = document.getElementById('pfxContent');
if (!pfxCurrentData) return;
const d = pfxCurrentData;
if (pfxActiveTab === 'ann') pfxRenderTable(el, d.announcements || [], ['Timestamp','Prefix','Origin AS','RPKI'], pfxRowAnn);
if (pfxActiveTab === 'wd') pfxRenderTable(el, d.withdrawals || [], ['Timestamp','Prefix','Peer'], pfxRowWd);
if (pfxActiveTab === 'orig') pfxRenderTable(el, d.origin_changes || [], ['Timestamp','Prefix','From AS','To AS'], pfxRowOrig);
if (pfxActiveTab === 'rpki') pfxRenderTable(el, d.rpki_issues || [], ['Timestamp','Prefix','Origin AS','Status'], pfxRowRpki);
}
function pfxRowAnn(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), 'AS'+(r.origin|0), pfxRpkiBadge(r.rpki_status)]; }
function pfxRowWd(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), escHtml(r.peer||'')]; }
function pfxRowOrig(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), '<span style="color:var(--red)">AS'+(r.from_origin|0)+'</span>', '<span style="color:var(--green)">AS'+(r.to_origin|0)+'</span>']; }
function pfxRowRpki(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), 'AS'+(r.origin|0), pfxRpkiBadge(r.rpki_status)]; }
function pfxRenderTable(el, rows, headers, rowFn) {
el.textContent = '';
if (!rows.length) { el.textContent = 'No data in this time range.'; el.style.color = 'var(--dim)'; return; }
el.style.color = '';
const wrap = document.createElement('div'); wrap.style.overflowX = 'auto';
const tbl = document.createElement('table');
tbl.style.cssText = 'width:100%;border-collapse:collapse;font-size:.72rem';
const thead = tbl.createTHead(); const hrow = thead.insertRow();
headers.forEach(h => {
const th = document.createElement('th');
th.textContent = h;
th.style.cssText = 'text-align:left;padding:.3rem .5rem;border-bottom:1px solid var(--border);color:var(--dim);white-space:nowrap';
hrow.appendChild(th);
});
const tbody = tbl.createTBody();
rows.slice(0, 200).forEach((r, i) => {
const tr = tbody.insertRow();
tr.style.background = i % 2 ? 'rgba(255,255,255,.02)' : 'transparent';
rowFn(r).forEach(cell => {
const td = tr.insertCell();
td.style.cssText = 'padding:.25rem .5rem;border-bottom:1px solid rgba(255,255,255,.04);white-space:nowrap';
td.innerHTML = cell; // cell values: escHtml() for external data, static color spans only
});
});
wrap.appendChild(tbl);
el.appendChild(wrap);
if (rows.length > 200) {
const note = document.createElement('div');
note.textContent = 'Showing 200 of ' + rows.length + ' entries.';
note.style.cssText = 'color:var(--dim);font-size:.7rem;margin-top:.5rem';
el.appendChild(note);
}
}
function pfxRenderLive(asn) {
if (!asn) return;
if (pfxLiveWs) pfxLiveWs.close();
pfxLiveLines = [];
const el = document.getElementById('pfxContent');
el.textContent = '';
const statusDiv = document.createElement('div');
statusDiv.style.cssText = 'color:var(--green);margin-bottom:.5rem;font-size:.75rem';
statusDiv.textContent = '● Connecting to RIPE RIS Live…';
const log = document.createElement('div');
log.id = 'pfxLiveLog';
log.style.cssText = 'font-size:.7rem;line-height:1.6;max-height:400px;overflow-y:auto';
el.appendChild(statusDiv);
el.appendChild(log);
try {
pfxLiveWs = new WebSocket('wss://ris-live.ripe.net/v1/ws/');
pfxLiveWs.onopen = () => {
pfxLiveWs.send(JSON.stringify({ type:'ris_subscribe', data:{ type:'UPDATE', path: String(asn) + '$', 'more-specific': true } }));
statusDiv.textContent = '';
statusDiv.appendChild(document.createTextNode('● Live — AS' + asn + ' (RIPE RIS) '));
const stop = document.createElement('button');
stop.textContent = 'STOP';
stop.style.cssText = 'margin-left:.5rem;font-family:var(--mono);font-size:.6rem;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer;padding:.1rem .4rem';
stop.onclick = () => { if (pfxLiveWs) pfxLiveWs.close(); };
statusDiv.appendChild(stop);
};
pfxLiveWs.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type !== 'ris_message') return;
const d = msg.data; if (!d) return;
const ts = escHtml(new Date((d.timestamp||0)*1000).toISOString().slice(11,19));
const peer = escHtml(String(d.peer||''));
(d.announcements||[]).forEach(a => pfxLivePush('<span style="color:var(--green)">ANN</span> '+ts+' <b>'+escHtml(String(a.prefix||''))+'</b> peer:'+peer));
(d.withdrawals||[]).forEach(w => pfxLivePush('<span style="color:var(--red)">WD&nbsp;</span> '+ts+' <b>'+escHtml(String(w.prefix||''))+'</b> peer:'+peer));
} catch(_) {}
};
pfxLiveWs.onclose = () => { statusDiv.textContent = '○ Disconnected'; };
pfxLiveWs.onerror = () => { statusDiv.textContent = 'WebSocket error — RIPE RIS Live unreachable.'; statusDiv.style.color = 'var(--red)'; };
} catch(e) { statusDiv.textContent = 'Error: ' + e.message; statusDiv.style.color = 'var(--red)'; }
}
function pfxLivePush(line) {
pfxLiveLines.unshift(line);
if (pfxLiveLines.length > 200) pfxLiveLines.pop();
const log = document.getElementById('pfxLiveLog');
if (log) log.innerHTML = pfxLiveLines.map(l => '<div>' + l + '</div>').join('');
}
function pfxTs(ts) { return ts ? escHtml(String(ts).replace('T',' ').slice(0,19)) : '—'; }
function pfxRpkiBadge(s) {
if (s === 'valid') return '<span style="color:var(--green)">✓ valid</span>';
if (s === 'invalid') return '<span style="color:var(--red)">✗ invalid</span>';
return '<span style="color:var(--dim)">? unknown</span>';
}
// ── Contacts & Registration ──────────────────────────────────── // ── Contacts & Registration ────────────────────────────────────
function renderContacts(d) { function renderContacts(d) {
const card = document.getElementById('contactsCard'); const card = document.getElementById('contactsCard');

297
server.js
View File

@ -405,7 +405,7 @@ setInterval(runHijackCheck, 30 * 60 * 1000);
const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)"; const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
// Static geocode cache for major networking cities (fallback when PDB facility coords missing) // Static geocode cache for major networking cities (fallback when PDB facility coords missing)
const CITY_COORDS = { const CITY_COORDS = {
@ -489,6 +489,77 @@ const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes
// ============================================================
// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN
// ============================================================
const rdapCache = new Map(); // key: asn string, value: { data, ts }
const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
function rdapCacheGet(asn) {
const e = rdapCache.get(String(asn));
if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data;
return undefined; // undefined = not cached, null = cached miss
}
function rdapCacheSet(asn, data) {
if (rdapCache.size > 5000) {
rdapCache.delete(rdapCache.keys().next().value);
}
rdapCache.set(String(asn), { data, ts: Date.now() });
}
// ============================================================
// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering
// ============================================================
const whoisCache = new Map(); // key: asn string, value: { data, ts }
const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
function whoisCacheGet(asn) {
const e = whoisCache.get(String(asn));
if (e && (Date.now() - e.ts) < WHOIS_CACHE_TTL) return e.data;
return undefined;
}
function whoisCacheSet(asn, data) {
if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
whoisCache.set(String(asn), { data, ts: Date.now() });
}
// ============================================================
// Quick-IX Cache — 1h TTL, for Peering Recommendations
// ============================================================
const quickIxCache = new Map(); // key: asn string, value: { data, ts }
const QUICK_IX_CACHE_TTL = 60 * 60 * 1000;
function quickIxCacheGet(asn) {
const e = quickIxCache.get(String(asn));
if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data;
return undefined;
}
function quickIxCacheSet(asn, data) {
if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value);
quickIxCache.set(String(asn), { data, ts: Date.now() });
}
// ============================================================
// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429
// ============================================================
let bgproutesVpCache = null;
let bgproutesVpCacheTs = 0;
const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour
// ============================================================
// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs
// ============================================================
const bgproutesResultCache = new Map();
const aspaResultCache = new Map();
const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
function resultCacheGet(map, key) {
const e = map.get(String(key));
if (e && (Date.now() - e.ts) < RESULT_CACHE_TTL) return e.data;
return undefined;
}
function resultCacheSet(map, key, data) {
if (map.size > 2000) map.delete(map.keys().next().value);
map.set(String(key), { data, ts: Date.now() });
}
// ============================================================ // ============================================================
// MANRS Participants Cache (scraped from public HTML page, 24h TTL) // MANRS Participants Cache (scraped from public HTML page, 24h TTL)
// ============================================================ // ============================================================
@ -1218,6 +1289,7 @@ function postJSON(url, body, options) {
return new Promise((resolve) => { return new Promise((resolve) => {
const data = JSON.stringify(body); const data = JSON.stringify(body);
const parsed = new URL(url); const parsed = new URL(url);
const timeout = (options && options.timeout) || 10000;
const reqOptions = { const reqOptions = {
hostname: parsed.hostname, hostname: parsed.hostname,
port: parsed.port || 443, port: parsed.port || 443,
@ -1230,10 +1302,15 @@ function postJSON(url, body, options) {
...(options && options.headers ? options.headers : {}), ...(options && options.headers ? options.headers : {}),
}, },
}; };
let done = false;
const timer = setTimeout(() => { if (!done) { done = true; req.destroy(); resolve(null); } }, timeout);
const req = https.request(reqOptions, (res) => { const req = https.request(reqOptions, (res) => {
let chunks = ""; let chunks = "";
res.on("data", (chunk) => (chunks += chunk)); res.on("data", (chunk) => (chunks += chunk));
res.on("end", () => { res.on("end", () => {
if (done) return;
done = true;
clearTimeout(timer);
try { try {
resolve(JSON.parse(chunks)); resolve(JSON.parse(chunks));
} catch (_e) { } catch (_e) {
@ -1241,7 +1318,7 @@ function postJSON(url, body, options) {
} }
}); });
}); });
req.on("error", () => resolve(null)); req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
req.write(data); req.write(data);
req.end(); req.end();
}); });
@ -1803,6 +1880,14 @@ async function fetchWhois(resource) {
result.type = "aut-num"; result.type = "aut-num";
const asn = trimmed.replace(/^AS/i, ""); const asn = trimmed.replace(/^AS/i, "");
// Check cache first
const cached = whoisCacheGet(asn);
if (cached !== undefined) {
result.data = cached;
if (!cached) result.error = "Not found in any RIR database (cached)";
return result;
}
// Try RIPE first // Try RIPE first
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null); const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
if (ripeData && ripeData.objects && ripeData.objects.object) { if (ripeData && ripeData.objects && ripeData.objects.object) {
@ -1826,9 +1911,10 @@ async function fetchWhois(resource) {
export: parsed["export"] || [], export: parsed["export"] || [],
remarks: parsed["remarks"] || [], remarks: parsed["remarks"] || [],
}; };
whoisCacheSet(asn, result.data);
} }
// If RIPE didn't find it, try all other RIRs via RDAP in parallel // If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
if (!result.data) { if (!result.data) {
const rdapEndpoints = [ const rdapEndpoints = [
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn }, { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
@ -1837,7 +1923,7 @@ async function fetchWhois(resource) {
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn }, { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
]; ];
const rdapResults = await Promise.all(rdapEndpoints.map((ep) => const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
fetchJSON(ep.url, { timeout: 5000 }).then((d) => { fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
if (!d || d.errorCode || !d.handle) return null; if (!d || d.errorCode || !d.handle) return null;
return { source: ep.name, data: d }; return { source: ep.name, data: d };
}).catch(() => null) }).catch(() => null)
@ -1868,8 +1954,10 @@ async function fetchWhois(resource) {
export: [], export: [],
remarks: remarks, remarks: remarks,
}; };
whoisCacheSet(asn, result.data);
} else { } else {
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)"; result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
whoisCacheSet(asn, null); // cache miss to avoid repeated hammering
} }
} }
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) { } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
@ -2562,6 +2650,11 @@ const server = http.createServer(async (req, res) => {
res.writeHead(400); res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
} }
const cachedAspa = resultCacheGet(aspaResultCache, rawAsn);
if (cachedAspa !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cachedAspa));
}
const start = Date.now(); const start = Date.now();
let _aspaDone = false; let _aspaDone = false;
const _aspaTimer = setTimeout(() => { const _aspaTimer = setTimeout(() => {
@ -2570,11 +2663,11 @@ const server = http.createServer(async (req, res) => {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) })); res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
} }
}, 18000); }, 12000);
try { try {
const [lgData, neighbourData] = await Promise.all([ const [lgData, neighbourData] = await Promise.all([
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 8000 }), fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null),
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 8000 }), fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }),
]); ]);
const rrcs = lgData?.data?.rrcs || []; const rrcs = lgData?.data?.rrcs || [];
@ -2649,9 +2742,7 @@ const server = http.createServer(async (req, res) => {
_aspaDone = true; _aspaDone = true;
clearTimeout(_aspaTimer); clearTimeout(_aspaTimer);
const duration = Date.now() - start; const duration = Date.now() - start;
return res.end( const aspaResult = {
JSON.stringify(
{
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
asn: parseInt(rawAsn), asn: parseInt(rawAsn),
detected_providers: detectedProviders, detected_providers: detectedProviders,
@ -2664,11 +2755,9 @@ const server = http.createServer(async (req, res) => {
total_paths_seen: asPaths.length, total_paths_seen: asPaths.length,
sample_paths: samplePaths, sample_paths: samplePaths,
}, },
}, };
null, resultCacheSet(aspaResultCache, rawAsn, aspaResult);
2 return res.end(JSON.stringify(aspaResult, null, 2));
)
);
} catch (err) { } catch (err) {
if (!_aspaDone) { if (!_aspaDone) {
_aspaDone = true; _aspaDone = true;
@ -2689,13 +2778,27 @@ const server = http.createServer(async (req, res) => {
res.writeHead(400); res.writeHead(400);
return res.end(JSON.stringify({ error: "Need asn or prefix parameter" })); return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
} }
const cacheKeyBgr = rawAsn || prefix;
const cachedBgr = resultCacheGet(bgproutesResultCache, cacheKeyBgr);
if (cachedBgr !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cachedBgr));
}
const start = Date.now(); const start = Date.now();
try { try {
const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null }; const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null };
const vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", { // Use module-level vantage_points cache (1h TTL) to prevent 429 flooding
let vpData = null;
if (bgproutesVpCache && (Date.now() - bgproutesVpCacheTs) < BGPROUTES_VP_TTL) {
vpData = bgproutesVpCache;
} else {
vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", {
headers: { "x-api-key": BGPROUTES_API_KEY }, headers: { "x-api-key": BGPROUTES_API_KEY },
timeout: 10000,
}); });
if (vpData && !vpData.error) { bgproutesVpCache = vpData; bgproutesVpCacheTs = Date.now(); }
}
if (vpData && !vpData.error) { if (vpData && !vpData.error) {
const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []); const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []);
@ -2743,6 +2846,7 @@ const server = http.createServer(async (req, res) => {
try { try {
const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, { const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, {
headers: { "x-api-key": BGPROUTES_API_KEY }, headers: { "x-api-key": BGPROUTES_API_KEY },
timeout: 6000,
}); });
if (ribData && ribData.data) { if (ribData && ribData.data) {
@ -2794,6 +2898,7 @@ const server = http.createServer(async (req, res) => {
} }
result.meta.duration_ms = Date.now() - start; result.meta.duration_ms = Date.now() - start;
resultCacheSet(bgproutesResultCache, cacheKeyBgr, result);
return res.end(JSON.stringify(result, null, 2)); return res.end(JSON.stringify(result, null, 2));
} catch (err) { } catch (err) {
res.writeHead(500); res.writeHead(500);
@ -3316,36 +3421,35 @@ const server = http.createServer(async (req, res) => {
} }
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null; const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
// RDAP: try all 5 RIRs in parallel, take the first valid response (fast race)
const rdapForReg = [
"https://rdap.db.ripe.net/autnum/" + asn,
"https://rdap.arin.net/registry/autnum/" + asn,
"https://rdap.apnic.net/autnum/" + asn,
"https://rdap.lacnic.net/rdap/autnum/" + asn,
"https://rdap.afrinic.net/rdap/autnum/" + asn,
];
const promises = [ // RDAP: check module-level cache first, only hit RIR endpoints on cache miss
timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 })), const rdapCached = rdapCacheGet(asn);
timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 })), const rdapPromise = rdapCached !== undefined
timedFetch("RIPE Stat Overview", fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn)), ? Promise.resolve(rdapCached)
timedFetch("RIPE Stat RIR", fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn)), : Promise.race([
timedFetch("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")), ...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn,
timedFetch("bgp.he.net", fetchBgpHeNet(asn)), "https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn,
timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 30000 })), "https://rdap.afrinic.net/rdap/autnum/"+asn].map(url =>
timedFetch("RIPE Stat PrefixSize", fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn)),
timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
timedFetch("RDAP Registration", Promise.race([
// All 5 RIR RDAP endpoints in parallel — first valid wins
...rdapForReg.map(url =>
fetchJSON(url, { timeout: 4000 }) fetchJSON(url, { timeout: 4000 })
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {})) .then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
.catch(() => new Promise(() => {})) .catch(() => new Promise(() => {}))
), ),
new Promise(resolve => setTimeout(() => resolve(null), 5000)), new Promise(resolve => setTimeout(() => resolve(null), 5000)),
])), ]).then(d => { rdapCacheSet(asn, d); return d; });
const promises = [
timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 20000 })),
timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 20000 })),
timedFetch("RIPE Stat Overview", fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn)),
timedFetch("RIPE Stat RIR", fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn)),
timedFetch("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")),
timedFetch("bgp.he.net", fetchBgpHeNet(asn)),
timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 12000 })),
timedFetch("RIPE Stat PrefixSize", fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn)),
timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
timedFetch("RDAP Registration", rdapPromise),
]; ];
const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises); const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises);
@ -4073,6 +4177,50 @@ const server = http.createServer(async (req, res) => {
} }
// ============================================================
// Quick-IX endpoint: /api/quick-ix?asn=X
// Lightweight: only IX connections from PeeringDB, 1h cached
// Used by Peering Recommendations to avoid 20x full lookups
// ============================================================
if (reqPath === "/api/quick-ix") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
if (!rawAsn) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Missing asn parameter" }));
}
const cached = quickIxCacheGet(rawAsn);
if (cached !== undefined) {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(cached));
}
try {
const [pdbNetData, pdbIxlanData] = await Promise.all([
fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null),
fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null),
]);
const netName = pdbNetData?.data?.[0]?.name || "";
const ixConnections = [];
if (pdbIxlanData && pdbIxlanData.data) {
pdbIxlanData.data.forEach((row) => {
ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 });
});
}
// Fall back to RIPE Stat if PeeringDB returns nothing
if (ixConnections.length === 0) {
const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null);
const ixs = rsStat?.data?.ixs || [];
ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 }));
}
const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections };
quickIxCacheSet(rawAsn, result);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500);
return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message }));
}
}
// ============================================================ // ============================================================
// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000 // Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
// ============================================================ // ============================================================
@ -4986,6 +5134,75 @@ ${html}
} }
} }
// ── Prefix Changes ──────────────────────────────────────────────
if (reqPath === '/api/prefix-changes') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: 'Missing ASN' })); }
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const hoursParam = Math.min(parseInt(url.searchParams.get('hours') || '1', 10), 168);
let starttime, endtime;
if (fromParam && toParam) {
starttime = new Date(fromParam).toISOString();
endtime = new Date(toParam).toISOString();
} else {
endtime = new Date().toISOString();
starttime = new Date(Date.now() - hoursParam * 3600000).toISOString();
}
try {
const updUrl = `https://stat.ripe.net/data/bgp-updates/data.json?resource=AS${rawAsn}&starttime=${encodeURIComponent(starttime)}&endtime=${encodeURIComponent(endtime)}&limit=1000`;
const raw = await fetchJSON(updUrl, { timeout: 25000 });
const updates = (raw && raw.data && raw.data.updates && raw.data.updates.updates) || [];
const announcements = [], withdrawals = [], originChanges = [], rpkiIssues = [];
const lastOriginByPrefix = {}, rpkiSeen = new Set();
for (const u of updates) {
const prefix = u.attrs && u.attrs.prefix; if (!prefix) continue;
const originRaw = u.attrs && u.attrs.origin;
const origin = originRaw ? parseInt(String(originRaw).replace('AS', ''), 10) : null;
const ts = u.timestamp || '';
const peer = u.peer || '';
if (u.type === 'A') {
const rpki = (origin && roaStore.ready) ? roaStore.validate(origin, prefix) : null;
const rpkiStatus = rpki ? rpki.status : 'unknown';
announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus });
if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) {
originChanges.push({ prefix, from_origin: lastOriginByPrefix[prefix], to_origin: origin, timestamp: ts, peer });
}
lastOriginByPrefix[prefix] = origin;
if (!rpkiSeen.has(prefix) && rpkiStatus !== 'valid') {
rpkiSeen.add(prefix);
rpkiIssues.push({ prefix, origin, rpki_status: rpkiStatus, timestamp: ts });
}
} else if (u.type === 'W') {
withdrawals.push({ prefix, timestamp: ts, peer });
}
}
res.writeHead(200);
return res.end(JSON.stringify({
asn: parseInt(rawAsn, 10),
time_range: { from: starttime, to: endtime },
total_updates: updates.length,
summary: { announcements: announcements.length, withdrawals: withdrawals.length, origin_changes: originChanges.length, rpki_issues: rpkiIssues.length },
announcements,
withdrawals,
origin_changes: originChanges,
rpki_issues: rpkiIssues,
}));
} catch(e) {
res.writeHead(500);
return res.end(JSON.stringify({ error: e.message }));
}
}
// 404 // 404
res.writeHead(404); res.writeHead(404);
res.end( res.end(