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:
parent
344ee15338
commit
35b89c05aa
76
CHANGELOG.md
76
CHANGELOG.md
@ -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 (1–10) 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
|
||||
|
||||
### 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
|
||||
|
||||
@ -113,47 +145,3 @@ All notable changes to PeerCortex are documented here.
|
||||
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation |
|
||||
| [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 |
|
||||
|
||||
## 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.
|
||||
|
||||
@ -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":"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-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"}
|
||||
|
||||
1749
deploy/server.js
1749
deploy/server.js
File diff suppressed because it is too large
Load Diff
@ -733,6 +733,36 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
<div id="hijackContent"></div>
|
||||
</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 -->
|
||||
<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">
|
||||
@ -1180,8 +1210,11 @@ async function doLookup() {
|
||||
|
||||
async function loadAspaData(asn) {
|
||||
$('aspaContent').innerHTML = '<div class="section-loading">Loading ASPA data...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
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; }
|
||||
var text = await resp.text();
|
||||
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; }
|
||||
renderAspa(d);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@ -1206,16 +1240,20 @@ function renderProviderGraphFromLookupFallback(asn) {
|
||||
|
||||
async function loadBgroutesData(asn) {
|
||||
$('bgroutesContent').innerHTML = '<div class="section-loading">Loading bgproutes.io data...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 12000);
|
||||
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();
|
||||
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;
|
||||
}
|
||||
renderBgroutes(d);
|
||||
} 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) {
|
||||
$('aspaDeepContent').innerHTML = '<div class="section-loading">Running RFC-compliant ASPA verification...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||||
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; }
|
||||
var text = await resp.text();
|
||||
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; }
|
||||
renderAspaDeep(d);
|
||||
} 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) {
|
||||
$('whoisContent').innerHTML = '<div class="section-loading">Loading WHOIS data...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
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; }
|
||||
var text = await resp.text();
|
||||
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; }
|
||||
renderWhois(d);
|
||||
} 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) {
|
||||
$('healthContent').innerHTML = '<div class="section-loading">Running comprehensive validation (13 checks)...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||||
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; }
|
||||
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; }
|
||||
@ -2919,6 +2968,7 @@ async function loadHealthReport(asn) {
|
||||
}
|
||||
renderHealthReport(d);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
$('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>';
|
||||
|
||||
// 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) {
|
||||
return fetch('/api/lookup?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
|
||||
var name = d.network ? d.network.name : 'AS' + targetAsn;
|
||||
var theirIx = (d.ix_presence && d.ix_presence.connections) || [];
|
||||
return fetch('/api/quick-ix?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
|
||||
var name = d.name || ('AS' + targetAsn);
|
||||
var theirIx = d.ix_connections || [];
|
||||
var theirIxIds = new Set(theirIx.map(function(ix) { return ix.ix_id; }));
|
||||
var common = [];
|
||||
myIxIds.forEach(function(id) { if (theirIxIds.has(id)) common.push(myIxNames[id] || 'IX-' + id); });
|
||||
@ -3903,6 +3953,13 @@ function loadNewFeatures(asn) {
|
||||
loadRpkiHistory(asn);
|
||||
loadAspath(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)
|
||||
setTimeout(() => {
|
||||
const raw = currentLookupData || {};
|
||||
@ -3925,6 +3982,172 @@ fetch('/api/visitors').then(r=>r.json()).then(d=>{
|
||||
}).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 </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 ────────────────────────────────────
|
||||
function renderContacts(d) {
|
||||
const card = document.getElementById('contactsCard');
|
||||
|
||||
317
server.js
317
server.js
@ -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)
|
||||
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_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)
|
||||
// ============================================================
|
||||
@ -1218,6 +1289,7 @@ function postJSON(url, body, options) {
|
||||
return new Promise((resolve) => {
|
||||
const data = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
const timeout = (options && options.timeout) || 10000;
|
||||
const reqOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
@ -1230,10 +1302,15 @@ function postJSON(url, body, options) {
|
||||
...(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) => {
|
||||
let chunks = "";
|
||||
res.on("data", (chunk) => (chunks += chunk));
|
||||
res.on("end", () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
resolve(JSON.parse(chunks));
|
||||
} 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.end();
|
||||
});
|
||||
@ -1803,6 +1880,14 @@ async function fetchWhois(resource) {
|
||||
result.type = "aut-num";
|
||||
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
|
||||
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) {
|
||||
@ -1826,9 +1911,10 @@ async function fetchWhois(resource) {
|
||||
export: parsed["export"] || [],
|
||||
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) {
|
||||
const rdapEndpoints = [
|
||||
{ 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 },
|
||||
];
|
||||
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;
|
||||
return { source: ep.name, data: d };
|
||||
}).catch(() => null)
|
||||
@ -1868,8 +1954,10 @@ async function fetchWhois(resource) {
|
||||
export: [],
|
||||
remarks: remarks,
|
||||
};
|
||||
whoisCacheSet(asn, result.data);
|
||||
} else {
|
||||
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)) {
|
||||
@ -2562,6 +2650,11 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(400);
|
||||
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();
|
||||
let _aspaDone = false;
|
||||
const _aspaTimer = setTimeout(() => {
|
||||
@ -2570,11 +2663,11 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
|
||||
}
|
||||
}, 18000);
|
||||
}, 12000);
|
||||
try {
|
||||
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/asn-neighbours/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: 4000 }),
|
||||
]);
|
||||
|
||||
const rrcs = lgData?.data?.rrcs || [];
|
||||
@ -2649,26 +2742,22 @@ const server = http.createServer(async (req, res) => {
|
||||
_aspaDone = true;
|
||||
clearTimeout(_aspaTimer);
|
||||
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,
|
||||
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
||||
aspa_declared_count: aspaDeclaredProviders.length,
|
||||
recommended_aspa: recommendedAspa,
|
||||
path_analysis: {
|
||||
total_paths_seen: asPaths.length,
|
||||
sample_paths: samplePaths,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const aspaResult = {
|
||||
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,
|
||||
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
||||
aspa_declared_count: aspaDeclaredProviders.length,
|
||||
recommended_aspa: recommendedAspa,
|
||||
path_analysis: {
|
||||
total_paths_seen: asPaths.length,
|
||||
sample_paths: samplePaths,
|
||||
},
|
||||
};
|
||||
resultCacheSet(aspaResultCache, rawAsn, aspaResult);
|
||||
return res.end(JSON.stringify(aspaResult, null, 2));
|
||||
} catch (err) {
|
||||
if (!_aspaDone) {
|
||||
_aspaDone = true;
|
||||
@ -2689,13 +2778,27 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(400);
|
||||
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();
|
||||
try {
|
||||
const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null };
|
||||
|
||||
const vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", {
|
||||
headers: { "x-api-key": BGPROUTES_API_KEY },
|
||||
});
|
||||
// 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 },
|
||||
timeout: 10000,
|
||||
});
|
||||
if (vpData && !vpData.error) { bgproutesVpCache = vpData; bgproutesVpCacheTs = Date.now(); }
|
||||
}
|
||||
|
||||
if (vpData && !vpData.error) {
|
||||
const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []);
|
||||
@ -2743,6 +2846,7 @@ const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, {
|
||||
headers: { "x-api-key": BGPROUTES_API_KEY },
|
||||
timeout: 6000,
|
||||
});
|
||||
|
||||
if (ribData && ribData.data) {
|
||||
@ -2794,6 +2898,7 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
result.meta.duration_ms = Date.now() - start;
|
||||
resultCacheSet(bgproutesResultCache, cacheKeyBgr, result);
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
@ -3316,36 +3421,35 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
// RDAP: check module-level cache first, only hit RIR endpoints on cache miss
|
||||
const rdapCached = rdapCacheGet(asn);
|
||||
const rdapPromise = rdapCached !== undefined
|
||||
? Promise.resolve(rdapCached)
|
||||
: Promise.race([
|
||||
...["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].map(url =>
|
||||
fetchJSON(url, { timeout: 4000 })
|
||||
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
|
||||
.catch(() => new Promise(() => {}))
|
||||
),
|
||||
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: 45000 })),
|
||||
timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 })),
|
||||
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: 30000 })),
|
||||
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", Promise.race([
|
||||
// All 5 RIR RDAP endpoints in parallel — first valid wins
|
||||
...rdapForReg.map(url =>
|
||||
fetchJSON(url, { timeout: 4000 })
|
||||
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
|
||||
.catch(() => new Promise(() => {}))
|
||||
),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 5000)),
|
||||
])),
|
||||
timedFetch("RDAP Registration", rdapPromise),
|
||||
];
|
||||
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
|
||||
// ============================================================
|
||||
@ -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
|
||||
res.writeHead(404);
|
||||
res.end(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user