The 519-line handler split into 12 independent, named check functions (checks.js), the bogon-detection nested functions pulled out on their own (bogon.js, no I/O, easiest to verify), and a slim orchestrator (index.js) that runs Phase 1 fetch, dispatches all checks via Promise.race+allSettled (same 5s-cap/10s-total behavior as before), scores, and assembles the response -- each piece under 50 lines except the orchestrator's main function itself. Verified via node --check, eslint no-undef (2 known bugs remain: compare, webhooks), and smoke-test harness (28/28 match, including a byte-for-byte match on /api/validate's own response -- the highest-value confirmation this decomposition preserved behavior exactly).
2401 lines
110 KiB
JavaScript
2401 lines
110 KiB
JavaScript
const fs = require("fs");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
const crypto = require("crypto");
|
|
|
|
// ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ──────────────
|
|
const localDb = require('./local-db-client');
|
|
console.log('[PeerCortex] Local DB client initialized');
|
|
|
|
|
|
// Load .env file
|
|
require('./src/backend/config');
|
|
|
|
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
|
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
|
|
|
|
// bio-rd gRPC client (optional — graceful fallback if unavailable)
|
|
let risClient = null;
|
|
try {
|
|
const { createRisClient } = require('./bio-rd-client');
|
|
const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost';
|
|
const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321');
|
|
risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT);
|
|
console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`);
|
|
} catch (e) {
|
|
console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)');
|
|
}
|
|
|
|
const {
|
|
PEERINGDB_API_KEY,
|
|
PEERINGDB_API_URL,
|
|
getPdbLocal,
|
|
queryPeeringDBLocal,
|
|
pdbSemaphore,
|
|
fetchPeeringDB,
|
|
fetchPeeringDBWithRetry,
|
|
} = require("./server/services/peeringdb");
|
|
|
|
// ── SMTP / Email ──────────────────────────────────────────────
|
|
const { sendFeedbackMail } = require('./src/backend/services/smtp');
|
|
// ── SMTP / Email ──────────────────────────────────────────────
|
|
|
|
const { loadVisitors, trackVisitor } = require("./server/visitors");
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// PEERCORTEX v0.6.1 — New Features
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
// ── BGP Community Database ─────────────────────────────────────
|
|
// Migrated to src/features/bgp-communities/bgp-community-db.ts
|
|
|
|
// ── Hijack Monitoring ──────────────────────────────────────────
|
|
const {
|
|
DATA_DIR,
|
|
loadHijackSubs,
|
|
loadHijackAlerts,
|
|
loadWebhookSubs,
|
|
saveWebhookSubs,
|
|
saveHijackAlerts,
|
|
saveHijackSubs,
|
|
} = require("./server/hijack-monitoring/store");
|
|
const { webhookSignature, deliverWebhook, notifyWebhooks } = require("./server/hijack-monitoring/webhooks");
|
|
const { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck } = require("./server/hijack-monitoring/monitor");
|
|
|
|
const {
|
|
aspaAdoptionDailyHistory,
|
|
recordAspaAdoptionSnapshot,
|
|
getAdoptionTrend,
|
|
buildAspaAdoptionDashboard,
|
|
} = require("./server/aspa-adoption/tracker");
|
|
|
|
const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption");
|
|
|
|
const { UA } = require("./server/data/constants");
|
|
const { CITY_COORDS } = require("./server/data/city-coords");
|
|
|
|
// ============================================================
|
|
const {
|
|
scoreRingSvg,
|
|
pdfBaseCSS,
|
|
buildPdfHtml,
|
|
buildComparisonPdfHtml,
|
|
formatLabel,
|
|
escHtml,
|
|
} = require("./server/pdf-export/templates");
|
|
const {
|
|
getPuppeteerBrowser,
|
|
pdfCacheGet,
|
|
pdfCacheSet,
|
|
renderHtmlToPdf,
|
|
} = require("./server/pdf-export/renderer");
|
|
|
|
const {
|
|
responseCache,
|
|
cacheGet,
|
|
cacheSet,
|
|
RESULT_CACHE_TTL,
|
|
CACHE_TTL_LOOKUP,
|
|
CACHE_TTL_LOOKUP_DEGRADED,
|
|
CACHE_TTL_DEFAULT,
|
|
rdapCacheGet,
|
|
rdapCacheSet,
|
|
whoisCacheGet,
|
|
whoisCacheSet,
|
|
WHOIS_NOT_FOUND_CACHE_TTL,
|
|
quickIxCacheGet,
|
|
quickIxCacheSet,
|
|
bgproutesResultCache,
|
|
aspaResultCache,
|
|
validateResultCache,
|
|
resultCacheGet,
|
|
resultCacheSet,
|
|
} = require("./server/caches/response-caches");
|
|
// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16:
|
|
// confirmed dead code, never read anywhere in the file.
|
|
|
|
const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs");
|
|
|
|
// ============================================================
|
|
// subCableCache/globalFacCache removed 2026-07-16: confirmed dead code,
|
|
// never read anywhere in the file.
|
|
|
|
const { roaStore } = require("./server/caches/roa-store");
|
|
|
|
const {
|
|
rpkiAspaMap,
|
|
getRpkiAspaLastFetch,
|
|
fetchRpkiAspaFeed,
|
|
ensureAspaCache,
|
|
lookupAspaFromRpki,
|
|
validateRPKIWithCache,
|
|
fetchRipeRpkiValidator,
|
|
crossCheckRpki,
|
|
} = require("./server/services/rpki");
|
|
|
|
const { pdbSourceCache } = require("./server/caches/pdb-source-cache");
|
|
const {
|
|
ripeStatCache,
|
|
fetchRipeStatCached,
|
|
fetchRipeStatCachedFromApi,
|
|
fetchRipeStatCachedWithRetry,
|
|
saveRipeStatCacheToDisk,
|
|
loadRipeStatCacheFromDisk,
|
|
Semaphore,
|
|
} = require("./server/services/ripe-stat");
|
|
|
|
|
|
const {
|
|
fetchJSON,
|
|
fetchJSONWithRetry,
|
|
fetchHTML,
|
|
postJSON,
|
|
fetchBgproutesVisibility,
|
|
} = require("./server/services/http-helpers");
|
|
// checkRateLimit/rateLimitMap removed 2026-07-16: dead code, never called by
|
|
// the request handler (confirmed via the server.js structural exploration).
|
|
|
|
const { resolveASNames } = require("./server/resolve-as-names");
|
|
|
|
|
|
|
|
const {
|
|
hasAsSet,
|
|
hopCheck,
|
|
collapsePrepends,
|
|
verifyUpstream,
|
|
verifyDownstream,
|
|
detectValleys,
|
|
buildAspaStore,
|
|
calculateAspaReadinessScore,
|
|
} = require("./server/aspa-verification/engine");
|
|
|
|
|
|
const { fetchBgpHeNet } = require("./server/bgp-he-net");
|
|
|
|
const { fetchTopology } = require("./server/topology");
|
|
|
|
const { computeResilienceScore } = require("./server/resilience-score");
|
|
const { computeRouteLeakDetection } = require("./server/route-leak");
|
|
const { fetchWhois } = require("./server/whois");
|
|
|
|
// ============================================================
|
|
// Internal API Server Proxy
|
|
// ============================================================
|
|
// Several routes were extracted into src/api/ + src/features/*/routes.ts
|
|
// (Fastify, dist/api/index.js, run as a separate PM2 process on
|
|
// API_SERVER_PORT) but were never re-wired here after that migration —
|
|
// they just silently 404'd. This forwards those specific paths through
|
|
// rather than reimplementing them inline a second time.
|
|
const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10);
|
|
|
|
function proxyToApiServer(req, res, targetUrl) {
|
|
const proxyReq = http.request(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port: API_SERVER_PORT,
|
|
path: targetUrl,
|
|
method: req.method,
|
|
headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }),
|
|
},
|
|
(proxyRes) => {
|
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
proxyRes.pipe(res);
|
|
}
|
|
);
|
|
proxyReq.on("error", (err) => {
|
|
console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message);
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "API server unavailable", detail: err.message }));
|
|
}
|
|
});
|
|
req.pipe(proxyReq);
|
|
}
|
|
|
|
// ============================================================
|
|
// HTTP Server
|
|
// ============================================================
|
|
|
|
const staticRoutes = require("./server/routes/static");
|
|
const searchRoutes = require("./server/routes/search");
|
|
const feedbackRoutes = require("./server/routes/feedback");
|
|
const liaRoutes = require("./server/routes/lia");
|
|
const healthRoute = require("./server/routes/health");
|
|
const aspaVerifyRoute = require("./server/routes/aspa-verify");
|
|
const aspaLegacyRoute = require("./server/routes/aspa-legacy");
|
|
const bgpRoute = require("./server/routes/bgp");
|
|
const validateRoute = require("./server/routes/validate");
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
return res.end();
|
|
}
|
|
|
|
let url, reqPath;
|
|
try {
|
|
url = new URL(req.url, "http://localhost");
|
|
reqPath = url.pathname;
|
|
} catch (_urlErr) {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request');
|
|
}
|
|
|
|
// Serve static files — host-based routing
|
|
const host = (req.headers.host || '').split(':')[0];
|
|
|
|
if (reqPath === "/" || reqPath === "/index.html") {
|
|
return staticRoutes.handleRoot(req, res, host, reqPath);
|
|
}
|
|
|
|
// Direct access to editorial version
|
|
if (reqPath === "/index-editorial.html") {
|
|
return staticRoutes.handleIndexEditorial(req, res);
|
|
}
|
|
|
|
// Name Search (RIPE Stat + PeeringDB combined)
|
|
if (reqPath === "/api/search") {
|
|
return searchRoutes.handleSearch(req, res);
|
|
}
|
|
|
|
// GET /api/visitors — unique visitor count
|
|
if (reqPath === "/api/visitors" && req.method === "GET") {
|
|
return searchRoutes.handleVisitors(req, res);
|
|
}
|
|
|
|
// OPTIONS preflight (CORS)
|
|
if (reqPath === "/api/feedback" && req.method === "OPTIONS") {
|
|
return feedbackRoutes.handleOptions(req, res);
|
|
}
|
|
|
|
// POST /api/feedback — submit feedback entry
|
|
if (reqPath === "/api/feedback" && req.method === "POST") {
|
|
return feedbackRoutes.handlePost(req, res);
|
|
}
|
|
|
|
// GET /api/feedback?token=... — admin: fetch all entries as JSON
|
|
if (reqPath === "/api/feedback" && req.method === "GET") {
|
|
return feedbackRoutes.handleGet(req, res, url);
|
|
}
|
|
|
|
// Serve favicon
|
|
if (reqPath === "/favicon.ico") {
|
|
return staticRoutes.handleFavicon(req, res);
|
|
}
|
|
|
|
// Lia's Atlas Paradise - Easter egg page
|
|
if (reqPath === "/lia" || reqPath === "/lia/") {
|
|
return staticRoutes.handleLia(req, res);
|
|
}
|
|
|
|
// Lia's Atlas Paradise: Atlas probe coverage endpoint
|
|
if (reqPath === "/api/atlas/coverage") {
|
|
return liaRoutes.handleAtlasCoverage(req, res);
|
|
}
|
|
|
|
// Lia's Paradise: File parsing endpoint (for binary uploads)
|
|
if (reqPath === "/api/lia/parse-file" && req.method === "POST") {
|
|
return liaRoutes.handleParseFile(req, res);
|
|
}
|
|
|
|
// Lia's Paradise: Combined PeeringDB + Atlas coverage data
|
|
if (reqPath === "/api/lia/coverage") {
|
|
return liaRoutes.handleLiaCoverage(req, res);
|
|
}
|
|
|
|
res.setHeader("Content-Type", "application/json");
|
|
|
|
// Health endpoint — extended with cache status, ASPA metrics, and local DB stats
|
|
if (reqPath === "/api/health") {
|
|
return healthRoute.handleHealth(req, res);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
|
|
if (reqPath === "/api/aspa/verify") {
|
|
return aspaVerifyRoute.handleAspaVerify(req, res, url);
|
|
}
|
|
|
|
// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
|
|
if (reqPath === "/api/aspa") {
|
|
return aspaLegacyRoute.handleAspaLegacy(req, res, url);
|
|
}
|
|
|
|
// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
|
|
if (reqPath === "/api/bgp") {
|
|
return bgpRoute.handleBgp(req, res, url);
|
|
}
|
|
|
|
|
|
|
|
// Unified Validation endpoint: /api/validate?asn=X
|
|
if (reqPath === "/api/validate") {
|
|
return validateRoute.handleValidate(req, res, url);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// Main lookup endpoint: /api/lookup?asn=X
|
|
// ============================================================
|
|
if (reqPath === "/api/lookup") {
|
|
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
|
if (!rawAsn) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
|
}
|
|
const asn = rawAsn;
|
|
const cacheKey = "lookup:" + asn;
|
|
const cached = cacheGet(cacheKey);
|
|
if (cached) {
|
|
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
|
return res.end(JSON.stringify(cached));
|
|
}
|
|
const start = Date.now();
|
|
|
|
try {
|
|
// Phase 0: Get PDB net first — check L2 cache, then API with retry
|
|
let pdbNet = pdbSourceCache.get("net", asn);
|
|
if (!pdbNet) {
|
|
pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
|
|
if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
|
|
}
|
|
const net = pdbNet?.data?.[0] || {};
|
|
const netId = net.id;
|
|
|
|
// Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
|
|
const ixQuery = netId
|
|
? "/netixlan?net_id=" + netId + "&limit=1000"
|
|
: "/netixlan?asn=" + asn + "&limit=1000";
|
|
const ixCacheKey = netId ? String(netId) : "asn:" + asn;
|
|
|
|
// Check PDB source cache for IX/Fac data
|
|
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
|
|
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
|
|
|
|
// Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
|
|
const sourceTiming = {};
|
|
function timedFetch(name, promise) {
|
|
const ts = Date.now();
|
|
return Promise.race([
|
|
Promise.resolve(promise),
|
|
new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
|
|
])
|
|
.then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
|
|
.catch(function() { sourceTiming[name] = null; return null; });
|
|
}
|
|
|
|
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
|
|
|
|
// 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", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
|
|
timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
|
|
timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
|
|
timedFetch("RIPE Stat RIR", Promise.resolve(null)),
|
|
timedFetch("RIPE Atlas", Promise.resolve(null)),
|
|
timedFetch("bgp.he.net", Promise.resolve(null)),
|
|
timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
|
|
timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
|
|
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);
|
|
|
|
// Store PDB results in L2 source cache for future lookups
|
|
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
|
|
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
|
|
|
|
// local-db-client's getRipeStat* functions return status:"error" (not "ok") when
|
|
// the local Postgres DB query itself failed -- track which sources actually
|
|
// failed so we don't cache a DB hiccup's fake-empty result as if it were a
|
|
// genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
|
|
const degradedSources = [
|
|
["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
|
|
["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
|
|
["RIPE Stat PrefixSize", prefixSizeData],
|
|
].filter(([, v]) => v && v.status === "error").map(([name]) => name);
|
|
|
|
const prefixes = prefixData?.data?.prefixes || [];
|
|
const neighbours = neighbourData?.data?.neighbours || [];
|
|
const overview = overviewData?.data || {};
|
|
const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
|
|
|
|
// Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
|
|
const atlasProbes = atlasProbeData?.results || [];
|
|
const atlasConnected = atlasProbes.filter(p => {
|
|
const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
|
|
return sName === "connected";
|
|
});
|
|
const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
|
|
|
|
// RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
|
|
await ensureAspaCache();
|
|
const allPrefixes = prefixes.map((p) => p.prefix);
|
|
const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
|
|
|
|
const ixConnections = (ixlanData?.data || [])
|
|
.map((ix) => ({
|
|
ix_name: ix.name || "",
|
|
ix_id: ix.ix_id,
|
|
speed_mbps: ix.speed || 0,
|
|
ipv4: ix.ipaddr4 || null,
|
|
ipv6: ix.ipaddr6 || null,
|
|
city: ix.city || "",
|
|
is_rs_peer: ix.is_rs_peer === true,
|
|
}))
|
|
.sort((a, b) => b.speed_mbps - a.speed_mbps);
|
|
|
|
const facilitiesRaw = (facData?.data || []).map((f) => ({
|
|
fac_id: f.fac_id,
|
|
name: f.name || "",
|
|
city: f.city || "",
|
|
country: f.country || "",
|
|
}));
|
|
|
|
// Batch-fetch facility coordinates for map (max 50 facilities)
|
|
const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
|
|
let facCoordMap = {};
|
|
if (facIds.length > 0) {
|
|
try {
|
|
const chunks = [];
|
|
for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
|
|
const coordResults = await Promise.race([
|
|
Promise.all(chunks.map(chunk =>
|
|
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
|
|
)),
|
|
new Promise(r => setTimeout(() => r([]), 5000))
|
|
]);
|
|
(coordResults || []).forEach(res => {
|
|
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
|
|
});
|
|
} catch(e) { /* graceful degradation */ }
|
|
}
|
|
const facilities = facilitiesRaw.map(f => ({
|
|
...f,
|
|
latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
|
|
longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
|
|
}));
|
|
|
|
// Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
|
|
const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
|
|
let ixLocations = [];
|
|
if (uniqueIxIds.length > 0) {
|
|
try {
|
|
const ixFacData = await Promise.race([
|
|
fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
|
|
new Promise(r => setTimeout(() => r(null), 5000))
|
|
]);
|
|
const ixFacs = ixFacData?.data || [];
|
|
// Collect unique fac_ids we don't already have coords for
|
|
const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
|
|
if (extraFacIds.length > 0) {
|
|
const extraChunks = [];
|
|
for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25));
|
|
const extraRes = await Promise.race([
|
|
Promise.all(extraChunks.map(chunk =>
|
|
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
|
|
)),
|
|
new Promise(r => setTimeout(() => r([]), 4000))
|
|
]);
|
|
(extraRes || []).forEach(res => {
|
|
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
|
|
});
|
|
}
|
|
// Build IX locations: pick first facility with coords per IX
|
|
const ixNameMap = {};
|
|
ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
|
|
const seenIx = {};
|
|
ixFacs.forEach(f => {
|
|
if (seenIx[f.ix_id]) return;
|
|
const coords = facCoordMap[f.fac_id];
|
|
if (coords) {
|
|
seenIx[f.ix_id] = true;
|
|
ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
|
|
}
|
|
});
|
|
} catch(e) { /* graceful degradation */ }
|
|
}
|
|
|
|
const rpkiStatuses = rpkiAllResults;
|
|
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
|
|
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
|
|
const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
|
|
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
|
|
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
|
// must not lower a network's displayed RPKI coverage percentage.
|
|
const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
|
|
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
|
|
|
|
let upstreams = neighbours
|
|
.filter((n) => n.type === "left")
|
|
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
|
.sort((a, b) => b.power - a.power);
|
|
let downstreams = neighbours
|
|
.filter((n) => n.type === "right")
|
|
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
|
.sort((a, b) => b.power - a.power);
|
|
let peers = neighbours
|
|
.filter((n) => n.type === "uncertain" || n.type === "peer")
|
|
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
|
.sort((a, b) => b.power - a.power);
|
|
|
|
// Resolve empty AS names — all in parallel, with 3s timeout
|
|
const emptyNameNeighbours = [...upstreams, ...downstreams, ...peers].filter(n => !n.name);
|
|
if (emptyNameNeighbours.length > 0) {
|
|
const resolvePromise = Promise.all(
|
|
emptyNameNeighbours.map(n =>
|
|
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
|
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
|
.catch(() => {})
|
|
)
|
|
);
|
|
await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
|
|
}
|
|
|
|
// ---- Threat Intelligence Enrichment for Neighbors ----
|
|
// Enrich neighbor data with threat status from local threat_intel table
|
|
const threatEnrichNeighbors = async (neighbors) => {
|
|
const allNeighbors = [...neighbors];
|
|
const threatMap = {};
|
|
|
|
// Batch threat intel lookups (cap at 50 to avoid overwhelming DB)
|
|
const toCheck = allNeighbors.slice(0, 50);
|
|
const threatPromises = toCheck.map(async (n) => {
|
|
try {
|
|
// Try to get threat intel by AS number or typical AS IP pattern
|
|
// For now, we'll mark neighbors without direct IP threat data
|
|
const asNum = String(n.asn);
|
|
const threat = await localDb.getThreatIntel(asNum);
|
|
if (threat) {
|
|
threatMap[n.asn] = {
|
|
threat_level: threat.threat_level,
|
|
confidence_score: threat.confidence_score,
|
|
source: threat.source,
|
|
cached_at: threat.cached_at,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
// Gracefully skip on error
|
|
console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
|
}
|
|
});
|
|
|
|
// Run threat lookups with 4s timeout
|
|
await Promise.race([
|
|
Promise.all(threatPromises),
|
|
new Promise(r => setTimeout(r, 4000)),
|
|
]);
|
|
|
|
return threatMap;
|
|
};
|
|
|
|
const threatMap = await threatEnrichNeighbors([...upstreams, ...downstreams, ...peers]);
|
|
|
|
// Attach threat status to neighbor objects
|
|
const addThreatToNeighbor = (n) => ({
|
|
...n,
|
|
threat_level: threatMap[n.asn]?.threat_level || null,
|
|
threat_confidence: threatMap[n.asn]?.confidence_score || null,
|
|
threat_source: threatMap[n.asn]?.source || null,
|
|
});
|
|
|
|
upstreams = upstreams.map(addThreatToNeighbor);
|
|
downstreams = downstreams.map(addThreatToNeighbor);
|
|
peers = peers.map(addThreatToNeighbor);
|
|
|
|
let rir = "";
|
|
let country = "";
|
|
// RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir')
|
|
if (Array.isArray(rirEntries) && rirEntries.length > 0) {
|
|
country = rirEntries[0]?.location || rirEntries[0]?.country || "";
|
|
rir = rirEntries[0]?.rir || "";
|
|
}
|
|
if (!rir && rirData?.data) {
|
|
const rirField = rirData.data.rirs || [];
|
|
if (rirField.length > 0) rir = rirField[0]?.rir || "";
|
|
}
|
|
// Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE")
|
|
if (!rir && rdapData && rdapData.port43) {
|
|
const p43 = (rdapData.port43 || "").toLowerCase();
|
|
if (p43.includes("ripe")) rir = "RIPE";
|
|
else if (p43.includes("arin")) rir = "ARIN";
|
|
else if (p43.includes("apnic")) rir = "APNIC";
|
|
else if (p43.includes("lacnic")) rir = "LACNIC";
|
|
else if (p43.includes("afrinic")) rir = "AFRINIC";
|
|
}
|
|
// Also derive RIR from RDAP links (URL of the RDAP endpoint that responded)
|
|
if (!rir && rdapData && rdapData.links) {
|
|
const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || "";
|
|
if (selfLink.includes("ripe")) rir = "RIPE";
|
|
else if (selfLink.includes("arin")) rir = "ARIN";
|
|
else if (selfLink.includes("apnic")) rir = "APNIC";
|
|
else if (selfLink.includes("lacnic")) rir = "LACNIC";
|
|
else if (selfLink.includes("afrinic")) rir = "AFRINIC";
|
|
}
|
|
// bgp.he.net country_code fallback (for unannounced/reserve ASNs)
|
|
if (!country && bgpHeData && bgpHeData.country_code) {
|
|
country = bgpHeData.country_code;
|
|
}
|
|
// Last resort: derive RIR from country code (common assignments)
|
|
if (!rir && country) {
|
|
const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("./server/data/rir-country-codes");
|
|
if (ARIN_CC.has(country)) rir = "ARIN";
|
|
else if (APNIC_CC.has(country)) rir = "APNIC";
|
|
else if (LACNIC_CC.has(country)) rir = "LACNIC";
|
|
else if (AFRINIC_CC.has(country)) rir = "AFRINIC";
|
|
else rir = "RIPE"; // Europe + rest = RIPE NCC
|
|
}
|
|
|
|
const duration = Date.now() - start;
|
|
|
|
// Compute routing visibility and prefix size distribution
|
|
const routingInfo = await (async function() {
|
|
const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
|
|
const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
|
|
var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;
|
|
|
|
// Visibility API returns per-RIS-collector data
|
|
// Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
|
|
// Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
|
|
var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
|
|
var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
|
|
var visTimedOut = !visibilityData || !visibilityData.data;
|
|
visibilities.forEach(function(v) {
|
|
if (!v || !v.probe) return;
|
|
var v4PeerCount = v.ipv4_full_table_peer_count || 0;
|
|
var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
|
|
var v6PeerCount = v.ipv6_full_table_peer_count || 0;
|
|
var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
|
|
v4Total += v4PeerCount;
|
|
v4Seeing += (v4PeerCount - v4NotSeeing);
|
|
v6Total += v6PeerCount;
|
|
v6Seeing += (v6PeerCount - v6NotSeeing);
|
|
});
|
|
if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
|
|
if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
|
|
// If visibility API timed out but we have prefixes, try bgproutes.io fallback
|
|
if (visTimedOut && prefixes.length > 0) {
|
|
var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
|
|
if (!fallbackPrefix) fallbackPrefix = prefixes[0];
|
|
if (fallbackPrefix) {
|
|
var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
|
|
if (bgprFallback && bgprFallback.vps_seeing > 0) {
|
|
// Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
|
|
var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
|
|
ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
|
|
ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
|
|
totalRisPeersV4 = bgprFallback.vps_seeing;
|
|
console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
|
|
} else {
|
|
ipv4VisAvg = -1;
|
|
ipv6VisAvg = -1;
|
|
console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
|
|
}
|
|
} else {
|
|
ipv4VisAvg = -1;
|
|
ipv6VisAvg = -1;
|
|
}
|
|
}
|
|
totalRisPeersV4 = v4Total;
|
|
totalRisPeersV6 = v6Total;
|
|
|
|
// Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
|
|
var psdData = (prefixSizeData && prefixSizeData.data) || {};
|
|
var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
|
|
var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
|
|
|
|
return {
|
|
ipv4_prefixes: ipv4Prefixes.length,
|
|
ipv6_prefixes: ipv6Prefixes.length,
|
|
ipv4_visibility_avg: ipv4VisAvg,
|
|
ipv6_visibility_avg: ipv6VisAvg,
|
|
total_ris_peers_v4: totalRisPeersV4,
|
|
total_ris_peers_v6: totalRisPeersV6,
|
|
prefix_sizes_v4: psV4,
|
|
prefix_sizes_v6: psV6,
|
|
};
|
|
})();
|
|
|
|
// ============================================================
|
|
// Multi-source cross-checks (run in parallel, non-blocking)
|
|
// ============================================================
|
|
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
|
|
let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
|
|
let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
|
|
|
|
try {
|
|
// RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
|
|
const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
|
|
const rpkiCrossResult = await Promise.race([
|
|
rpkiCrossPromise,
|
|
new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
|
|
]);
|
|
if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
|
|
} catch (_e) { /* cross-check failed, keep defaults */ }
|
|
|
|
// bgp.he.net fetching is deliberately disabled (see the hardcoded
|
|
// Promise.resolve(null) a few hundred lines up, "bgp.he.net" timedFetch) --
|
|
// bgpHeData is therefore always null and this whole branch, along with the
|
|
// prefix/neighbour cross-checks below, never actually runs. Left in place
|
|
// (not deleted) in case bgp.he.net fetching is re-enabled later, but
|
|
// overall_confidence below must not claim a 2-source agreement that never
|
|
// happened -- see the sources:1/2 handling further down.
|
|
// Prefix count cross-check: compare RIPE Stat vs bgp.he.net
|
|
if (bgpHeData) {
|
|
const heV4 = bgpHeData.prefixes_v4 || 0;
|
|
const heV6 = bgpHeData.prefixes_v6 || 0;
|
|
const heTotal = heV4 + heV6;
|
|
if (heTotal > 0) {
|
|
prefixCrossCheck.bgp_he_net = heTotal;
|
|
const ripeStat = prefixes.length;
|
|
if (ripeStat > 0 && heTotal > 0) {
|
|
const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
|
|
prefixCrossCheck.agreement = ratio >= 0.9;
|
|
const diff = Math.abs(ripeStat - heTotal);
|
|
prefixCrossCheck.note = diff === 0
|
|
? "Exact match"
|
|
: "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
|
|
}
|
|
} else {
|
|
prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
|
|
}
|
|
|
|
// Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
|
|
if (bgpHeData.peer_count != null) {
|
|
neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
|
|
}
|
|
} else {
|
|
prefixCrossCheck.note = "bgp.he.net data unavailable";
|
|
}
|
|
|
|
// Compute overall data quality. Only push a score for a cross-check that
|
|
// actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
|
|
// not silently count as a perfect 100 in the average, and confidence must
|
|
// reflect "nothing was actually cross-checked" rather than defaulting high.
|
|
const crossCheckScores = [];
|
|
if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
|
|
// Prefix agreement: convert to percentage
|
|
if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
|
|
const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
|
|
crossCheckScores.push(Math.round(pfxRatio * 100));
|
|
}
|
|
// Neighbour agreement
|
|
if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
|
|
const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
|
|
crossCheckScores.push(Math.round(nbrRatio * 100));
|
|
}
|
|
const avgAgreement = crossCheckScores.length > 0
|
|
? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
|
|
: null;
|
|
const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
|
|
|
|
const dataQuality = {
|
|
sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
|
|
cross_checks: {
|
|
rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
|
|
prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
|
|
neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
|
|
},
|
|
overall_confidence: overallConfidence,
|
|
overall_agreement_pct: avgAgreement,
|
|
};
|
|
|
|
// === IX Location Geocode Fallback ===
|
|
// Some IXPs have no facility coordinates in PeeringDB.
|
|
// Use ix_name city extraction + hard-coded IX→city map as fallback.
|
|
var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
|
|
ixConnections.forEach(function(conn) {
|
|
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
|
var name = conn.ix_name || "";
|
|
if (name) {
|
|
var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
for (var w = 0; w < words.length; w++) {
|
|
if (CITY_COORDS[words[w]]) {
|
|
ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
|
|
ixIdsWithCoords.add(conn.ix_id);
|
|
return;
|
|
}
|
|
if (w < words.length - 1) {
|
|
var tw = words[w] + " " + words[w + 1];
|
|
if (CITY_COORDS[tw]) {
|
|
ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
|
|
ixIdsWithCoords.add(conn.ix_id);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
// Hard-coded IX ID → city for well-known IXPs whose names don't contain city
|
|
var { IX_CITY_MAP } = require("./server/data/ix-city-map");
|
|
ixConnections.forEach(function(conn) {
|
|
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
|
var city = IX_CITY_MAP[conn.ix_id];
|
|
if (city && CITY_COORDS[city]) {
|
|
ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
|
|
}
|
|
});
|
|
|
|
const result = {
|
|
meta: {
|
|
service: "PeerCortex",
|
|
version: "0.6.9",
|
|
query: "AS" + asn,
|
|
duration_ms: duration,
|
|
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
|
|
timestamp: new Date().toISOString(),
|
|
rpki_prefixes_checked: rpkiTotal,
|
|
total_prefixes: prefixes.length,
|
|
degraded_sources: degradedSources,
|
|
},
|
|
network: {
|
|
asn: parseInt(asn),
|
|
name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown",
|
|
aka: net.aka || "",
|
|
org_name: (net.org && net.org.name) ? net.org.name : "",
|
|
website: net.website || "",
|
|
type: net.info_type || "",
|
|
policy: net.policy_general || "",
|
|
traffic: net.info_traffic || "",
|
|
ratio: net.info_ratio || "",
|
|
scope: net.info_scope || "",
|
|
notes: net.notes ? net.notes.substring(0, 500) : "",
|
|
peeringdb_id: netId || null,
|
|
rir: rir,
|
|
country: country,
|
|
city: net.city || "",
|
|
latitude: (net.latitude != null) ? net.latitude : null,
|
|
longitude: (net.longitude != null) ? net.longitude : null,
|
|
looking_glass: net.looking_glass || "",
|
|
route_server: net.route_server || "",
|
|
info_prefixes4: net.info_prefixes4 || 0,
|
|
info_prefixes6: net.info_prefixes6 || 0,
|
|
status: net.status || "",
|
|
peeringdb_created: net.created ? net.created.slice(0, 10) : "",
|
|
peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "",
|
|
},
|
|
prefixes: {
|
|
total: prefixes.length,
|
|
ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
|
|
ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
|
|
list: prefixes.map((p) => p.prefix),
|
|
cross_check: prefixCrossCheck,
|
|
},
|
|
rpki: {
|
|
coverage_percent: rpkiCoverage,
|
|
valid: rpkiValid,
|
|
invalid: rpkiInvalid,
|
|
not_found: rpkiNotFound,
|
|
unavailable: rpkiUnavailable,
|
|
checked: rpkiTotal,
|
|
details: rpkiStatuses,
|
|
cross_check: rpkiCrossCheck,
|
|
},
|
|
neighbours: {
|
|
total: neighbours.length,
|
|
upstream_count: upstreams.length,
|
|
downstream_count: downstreams.length,
|
|
peer_count: peers.length,
|
|
upstreams: upstreams.slice(0, 20),
|
|
downstreams: downstreams.slice(0, 20),
|
|
peers: peers.slice(0, 20),
|
|
cross_check: neighbourCrossCheck,
|
|
},
|
|
ix_presence: {
|
|
total_connections: ixConnections.length,
|
|
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
|
|
connections: ixConnections,
|
|
},
|
|
ix_locations: ixLocations,
|
|
facilities: {
|
|
total: facilities.length,
|
|
list: facilities,
|
|
},
|
|
routing: routingInfo,
|
|
resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
|
|
route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
|
|
bgp_he_net: bgpHeData || null,
|
|
atlas: {
|
|
total_probes: atlasProbes.length,
|
|
connected: atlasConnected.length,
|
|
disconnected: atlasProbes.length - atlasConnected.length,
|
|
anchors: atlasAnchors.length,
|
|
probes: atlasProbes.slice(0, 100).map(p => ({
|
|
id: p.id,
|
|
status: p.status_name || p.status || "Unknown",
|
|
is_anchor: p.is_anchor || false,
|
|
country: p.country_code || "",
|
|
prefix_v4: p.prefix_v4 || "",
|
|
prefix_v6: p.prefix_v6 || "",
|
|
description: p.description || "",
|
|
})),
|
|
},
|
|
data_quality: dataQuality,
|
|
source_timing: sourceTiming,
|
|
contacts: (() => {
|
|
const pocs = (pocData && pocData.data) ? pocData.data : [];
|
|
return pocs.slice(0, 20).map(p => ({
|
|
role: p.role || "",
|
|
name: p.name || "",
|
|
email: p.email || "",
|
|
url: p.url || "",
|
|
visible: p.visible || "",
|
|
}));
|
|
})(),
|
|
registration: (() => {
|
|
const events = (rdapData && rdapData.events) ? rdapData.events : [];
|
|
const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || "";
|
|
const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || "";
|
|
return {
|
|
created: created ? created.slice(0, 10) : "",
|
|
last_modified: lastChg ? lastChg.slice(0, 10) : "",
|
|
rir: rir || "",
|
|
handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn),
|
|
rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "",
|
|
};
|
|
})(),
|
|
_provenance: {
|
|
prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" },
|
|
neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" },
|
|
rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
|
|
ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
|
|
facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" },
|
|
bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
|
|
atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" },
|
|
resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" },
|
|
route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
|
|
registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" },
|
|
contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" },
|
|
},
|
|
};
|
|
|
|
// Update duration to include cross-check time
|
|
result.meta.duration_ms = Date.now() - start;
|
|
|
|
// A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
|
|
// retry soon instead of repeating a degraded result to every visitor.
|
|
cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
|
|
res.end(JSON.stringify(result, null, 2));
|
|
} catch (err) {
|
|
const duration = Date.now() - start;
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ============================================================
|
|
// AS Relationships endpoint: /api/relationships?asn=X
|
|
// Returns upstream providers, downstream customers, and peers
|
|
// with resolved names. Based on RIPE Stat asn-neighbours.
|
|
// ============================================================
|
|
if (reqPath === "/api/relationships") {
|
|
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
|
if (!rawAsn) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
|
}
|
|
const cacheKey = "relationships:" + rawAsn;
|
|
const cached = cacheGet(cacheKey);
|
|
if (cached) {
|
|
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
|
return res.end(JSON.stringify(cached));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
const neighbourData = await fetchRipeStatCached(
|
|
"https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1",
|
|
{ timeout: 8000 }
|
|
);
|
|
const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || [];
|
|
const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {};
|
|
|
|
const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0));
|
|
const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0));
|
|
const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0));
|
|
|
|
// Resolve AS names for top entries (upstreams + downstreams all, top 20 peers)
|
|
const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)];
|
|
const resolvedNames = {};
|
|
await Promise.race([
|
|
Promise.all(toResolve.map(n =>
|
|
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
|
.then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; })
|
|
.catch(() => {})
|
|
)),
|
|
new Promise(r => setTimeout(r, 5000)),
|
|
]);
|
|
|
|
// ---- Threat Intelligence Enrichment ----
|
|
// Enrich neighbors with threat status from local threat_intel table
|
|
const threatMap = {};
|
|
const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
|
|
const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => {
|
|
try {
|
|
const asNum = String(n.asn);
|
|
const threat = await localDb.getThreatIntel(asNum);
|
|
if (threat) {
|
|
threatMap[n.asn] = {
|
|
threat_level: threat.threat_level,
|
|
confidence_score: threat.confidence_score,
|
|
source: threat.source,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
|
}
|
|
});
|
|
await Promise.race([
|
|
Promise.all(threatPromises),
|
|
new Promise(r => setTimeout(r, 3000)),
|
|
]);
|
|
|
|
const fmt = n => ({
|
|
asn: n.asn,
|
|
name: resolvedNames[n.asn] || "",
|
|
power: n.power || 0,
|
|
v4_peers: n.v4_peers || 0,
|
|
v6_peers: n.v6_peers || 0,
|
|
threat_level: threatMap[n.asn]?.threat_level || null,
|
|
threat_confidence: threatMap[n.asn]?.confidence_score || null,
|
|
threat_source: threatMap[n.asn]?.source || null,
|
|
});
|
|
|
|
const result = {
|
|
asn: parseInt(rawAsn),
|
|
query_time: new Date().toISOString(),
|
|
duration_ms: Date.now() - start,
|
|
counts: {
|
|
upstreams: counts.left || upstreams.length,
|
|
downstreams: counts.right || downstreams.length,
|
|
peers_total: counts.unique || peers.length,
|
|
uncertain: counts.uncertain || peers.length,
|
|
},
|
|
upstreams: upstreams.map(fmt),
|
|
downstreams: downstreams.map(fmt),
|
|
peers: peers.slice(0, 50).map(fmt),
|
|
methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).",
|
|
source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn,
|
|
};
|
|
|
|
// A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
|
|
// otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
|
|
// match the existing /api/validate precedent (90s TTL for suspicious zero counts).
|
|
cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify(result, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Compare endpoint: /api/compare?asn1=X&asn2=Y
|
|
// ============================================================
|
|
if (reqPath === "/api/compare") {
|
|
const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
|
|
const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
|
|
if (!asn1 || !asn2) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
|
|
}
|
|
|
|
const compareCacheKey = "compare:" + asn1 + ":" + asn2;
|
|
const compareCached = cacheGet(compareCacheKey);
|
|
if (compareCached) {
|
|
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
|
return res.end(JSON.stringify(compareCached));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
// ALL calls in parallel — single batch
|
|
// Phase 1: Get PDB net objects + RIPE data
|
|
const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
|
|
fetchPeeringDB("/net?asn=" + asn1),
|
|
fetchPeeringDB("/net?asn=" + asn2),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
|
]);
|
|
|
|
const net1 = pdb1?.data?.[0] || {};
|
|
const net2 = pdb2?.data?.[0] || {};
|
|
const netId1 = net1.id;
|
|
const netId2 = net2.id;
|
|
|
|
// Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
|
|
const ixFacPromises = [];
|
|
ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
|
|
ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
|
|
ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
|
|
ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
|
|
const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
|
|
|
|
const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
|
|
const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
|
|
const ix1Names = {};
|
|
(ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
|
|
const ix2Names = {};
|
|
(ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
|
|
|
|
const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
|
|
const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
|
|
const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
|
|
|
|
const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
|
|
const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
|
|
const fac1Names = {};
|
|
(fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
|
|
const fac2Names = {};
|
|
(fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
|
|
|
|
const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
|
|
|
|
const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
|
const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
|
const up1Set = new Set(nb1.map((n) => n.asn));
|
|
const up2Set = new Set(nb2.map((n) => n.asn));
|
|
const nb1Map = {};
|
|
nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
|
|
const nb2Map = {};
|
|
nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
|
|
|
|
const commonUpstreams = [...up1Set]
|
|
.filter((a) => up2Set.has(a))
|
|
.map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
|
|
|
|
// ---- Threat Intelligence Enrichment for Common Upstreams ----
|
|
const threatMap = {};
|
|
const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
|
|
try {
|
|
const asNum = String(n.asn);
|
|
const threat = await localDb.getThreatIntel(asNum);
|
|
if (threat) {
|
|
threatMap[n.asn] = {
|
|
threat_level: threat.threat_level,
|
|
confidence_score: threat.confidence_score,
|
|
source: threat.source,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
|
}
|
|
});
|
|
await Promise.race([
|
|
Promise.all(threatPromises),
|
|
new Promise(r => setTimeout(r, 2000)),
|
|
]);
|
|
|
|
// Attach threat status to upstream objects
|
|
commonUpstreams.forEach((n) => {
|
|
if (threatMap[n.asn]) {
|
|
n.threat_level = threatMap[n.asn].threat_level;
|
|
n.threat_confidence = threatMap[n.asn].confidence_score;
|
|
n.threat_source = threatMap[n.asn].source;
|
|
}
|
|
});
|
|
|
|
// Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
|
|
const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
|
const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
|
const [, rpki1Results, rpki2Results] = await Promise.race([
|
|
Promise.all([
|
|
commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
|
|
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
|
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
|
.catch(() => {})
|
|
)) : Promise.resolve([]),
|
|
Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))),
|
|
Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))),
|
|
]),
|
|
new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
|
|
]);
|
|
|
|
const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
|
|
const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
|
|
const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
|
|
const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
|
|
|
|
const duration = Date.now() - start;
|
|
const compareResult = {
|
|
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
|
asn1: {
|
|
asn: parseInt(asn1),
|
|
name: net1.name || "Unknown",
|
|
ix_count: ix1Set.size,
|
|
fac_count: fac1Set.size,
|
|
upstream_count: up1Set.size,
|
|
rpki_coverage: rpki1Pct,
|
|
},
|
|
asn2: {
|
|
asn: parseInt(asn2),
|
|
name: net2.name || "Unknown",
|
|
ix_count: ix2Set.size,
|
|
fac_count: fac2Set.size,
|
|
upstream_count: up2Set.size,
|
|
rpki_coverage: rpki2Pct,
|
|
},
|
|
common_ixps: commonIX,
|
|
only_asn1_ixps: only1IX,
|
|
only_asn2_ixps: only2IX,
|
|
common_facilities: commonFac,
|
|
common_upstreams: commonUpstreams,
|
|
rpki_comparison: {
|
|
asn1_coverage: rpki1Pct,
|
|
asn2_coverage: rpki2Pct,
|
|
asn1_checked: rpki1Results.length,
|
|
asn2_checked: rpki2Results.length,
|
|
better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
|
|
},
|
|
};
|
|
cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
|
|
res.end(JSON.stringify(compareResult, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 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
|
|
// ============================================================
|
|
if (reqPath === "/api/peers/find") {
|
|
const ixName = url.searchParams.get("ix") || "";
|
|
const policy = url.searchParams.get("policy") || "";
|
|
const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
|
|
const netType = url.searchParams.get("type") || "";
|
|
|
|
if (!ixName) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
|
|
}
|
|
|
|
const start = Date.now();
|
|
try {
|
|
// Search for IX by name
|
|
const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
|
|
const ixResults = ixSearch?.data || [];
|
|
if (ixResults.length === 0) {
|
|
return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
|
|
}
|
|
|
|
// Use first matching IX
|
|
const ix = ixResults[0];
|
|
const ixId = ix.id;
|
|
|
|
// Get ixlan for this IX
|
|
const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
|
|
const ixlans = ixlanData?.data || [];
|
|
if (ixlans.length === 0) {
|
|
return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
|
|
}
|
|
|
|
const ixlanId = ixlans[0].id;
|
|
|
|
// Get all networks at this IX
|
|
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
|
|
const netixlans = netixlanData?.data || [];
|
|
|
|
// Get unique net_ids
|
|
const netIds = [...new Set(netixlans.map(n => n.net_id))];
|
|
|
|
// Fetch network details in batches
|
|
const networks = [];
|
|
const batchSize = 20;
|
|
for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
|
|
const batch = netIds.slice(i, i + batchSize);
|
|
const batchResults = await Promise.all(
|
|
batch.map(nid => fetchPeeringDB("/net/" + nid))
|
|
);
|
|
batchResults.forEach(r => {
|
|
if (r?.data?.[0]) networks.push(r.data[0]);
|
|
});
|
|
}
|
|
|
|
// Filter and rank
|
|
let filtered = networks.map(net => {
|
|
const nix = netixlans.filter(n => n.net_id === net.id);
|
|
const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
|
|
return {
|
|
asn: net.asn,
|
|
name: net.name || "",
|
|
policy: net.policy_general || "",
|
|
type: net.info_type || "",
|
|
speed_mbps: maxSpeed,
|
|
speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
|
|
traffic: net.info_traffic || "",
|
|
website: net.website || "",
|
|
peeringdb_id: net.id,
|
|
ipv4: nix[0]?.ipaddr4 || null,
|
|
ipv6: nix[0]?.ipaddr6 || null,
|
|
};
|
|
});
|
|
|
|
// Apply filters
|
|
if (policy) {
|
|
filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
|
|
}
|
|
if (minSpeed > 0) {
|
|
filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
|
|
}
|
|
if (netType) {
|
|
filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
|
|
}
|
|
|
|
// Sort by speed desc
|
|
filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);
|
|
|
|
// Also find common IXPs for each match (check if they share other IXPs)
|
|
const duration = Date.now() - start;
|
|
return res.end(JSON.stringify({
|
|
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
|
ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
|
|
total_members: netixlans.length,
|
|
filtered_count: filtered.length,
|
|
matches: filtered.slice(0, 50),
|
|
}, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Prefix Detail endpoint: /api/prefix/detail?prefix=X
|
|
// ============================================================
|
|
if (reqPath === "/api/prefix/detail") {
|
|
const prefix = url.searchParams.get("prefix") || "";
|
|
if (!prefix) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
const [routingStatus, visibility] = await Promise.all([
|
|
fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
|
|
]);
|
|
|
|
const origins = routingStatus?.data?.origins || [];
|
|
const firstSeen = routingStatus?.data?.first_seen?.time || null;
|
|
|
|
// RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
|
|
let rpkiStatus = "unknown";
|
|
let rpkiRoas = [];
|
|
const originAsn = origins.length > 0 ? origins[0].asn : null;
|
|
if (originAsn) {
|
|
try {
|
|
const localRpki = await validateRPKIWithCache(originAsn, prefix);
|
|
rpkiStatus = localRpki.status;
|
|
rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
|
|
} catch (e) {
|
|
console.error("[Prefix Detail] RPKI validation error:", e.message);
|
|
rpkiStatus = "unknown";
|
|
rpkiRoas = [];
|
|
}
|
|
}
|
|
var visData = visibility?.data?.visibilities || [];
|
|
var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
|
|
var visibilitySource = "ripe_stat";
|
|
// bgproutes.io fallback if RIPE Stat visibility returned no data
|
|
if (visData.length === 0 && BGPROUTES_API_KEY) {
|
|
var bgprVis = await fetchBgproutesVisibility(prefix);
|
|
if (bgprVis && bgprVis.vps_seeing > 0) {
|
|
risPeersSeeingIt = bgprVis.vps_seeing;
|
|
visData = []; // keep empty, use risPeersSeeingIt
|
|
visibilitySource = "bgproutes.io";
|
|
}
|
|
}
|
|
|
|
// Try to get IRR data
|
|
let irrStatus = "unknown";
|
|
try {
|
|
const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
|
|
const records = whoisData?.data?.records || [];
|
|
if (records.length > 0) irrStatus = "found";
|
|
} catch(_e) {}
|
|
|
|
const duration = Date.now() - start;
|
|
return res.end(JSON.stringify({
|
|
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
|
prefix: prefix,
|
|
origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
|
|
rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
|
|
irr_status: irrStatus,
|
|
visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
|
|
first_seen: firstSeen,
|
|
}, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// IX Detail endpoint: /api/ix/detail?ix_id=X
|
|
// ============================================================
|
|
if (reqPath === "/api/ix/detail") {
|
|
const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
|
|
if (!ixId) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
const [ixData, ixlanData] = await Promise.all([
|
|
fetchPeeringDB("/ix/" + ixId),
|
|
fetchPeeringDB("/ixlan?ix_id=" + ixId),
|
|
]);
|
|
|
|
const ix = ixData?.data?.[0] || {};
|
|
const ixlans = ixlanData?.data || [];
|
|
const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;
|
|
|
|
let members = [];
|
|
if (ixlanId) {
|
|
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
|
|
members = (netixlanData?.data || []).map(m => ({
|
|
asn: m.asn,
|
|
name: m.name || "",
|
|
speed_mbps: m.speed || 0,
|
|
speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
|
|
ipv4: m.ipaddr4 || null,
|
|
ipv6: m.ipaddr6 || null,
|
|
}));
|
|
}
|
|
|
|
// Sort by speed desc for top members
|
|
const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);
|
|
|
|
const duration = Date.now() - start;
|
|
return res.end(JSON.stringify({
|
|
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
|
ix: {
|
|
id: parseInt(ixId),
|
|
name: ix.name || "",
|
|
city: ix.city || "",
|
|
country: ix.country || "",
|
|
website: ix.website || "",
|
|
peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
|
|
},
|
|
total_members: members.length,
|
|
top_members_by_speed: sorted.slice(0, 20),
|
|
all_members: sorted,
|
|
}, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// Feature 25: Topology endpoint
|
|
// ============================================================
|
|
if (reqPath === "/api/topology") {
|
|
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
|
const depth = parseInt(url.searchParams.get("depth") || "2") || 2;
|
|
if (!rawAsn) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
const topology = await fetchTopology(parseInt(rawAsn), depth);
|
|
|
|
// ---- Threat Intelligence Enrichment for Topology Nodes ----
|
|
const threatMap = {};
|
|
const threatPromises = topology.nodes.slice(0, 100).map(async (node) => {
|
|
try {
|
|
const asNum = String(node.asn);
|
|
const threat = await localDb.getThreatIntel(asNum);
|
|
if (threat) {
|
|
threatMap[node.asn] = {
|
|
threat_level: threat.threat_level,
|
|
confidence_score: threat.confidence_score,
|
|
source: threat.source,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message);
|
|
}
|
|
});
|
|
await Promise.race([
|
|
Promise.all(threatPromises),
|
|
new Promise(r => setTimeout(r, 3000)),
|
|
]);
|
|
|
|
// Attach threat status to node objects
|
|
topology.nodes.forEach((node) => {
|
|
if (threatMap[node.asn]) {
|
|
node.threat_level = threatMap[node.asn].threat_level;
|
|
node.threat_confidence = threatMap[node.asn].confidence_score;
|
|
node.threat_source = threatMap[node.asn].source;
|
|
}
|
|
});
|
|
|
|
topology.meta = {
|
|
query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start,
|
|
timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length,
|
|
};
|
|
return res.end(JSON.stringify(topology, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "Topology query failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Feature 27: WHOIS endpoint
|
|
// ============================================================
|
|
if (reqPath === "/api/whois") {
|
|
const resource = url.searchParams.get("resource") || "";
|
|
if (!resource) {
|
|
res.writeHead(400);
|
|
return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" }));
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
const whoisResult = await fetchWhois(resource);
|
|
if (!whoisResult || typeof whoisResult !== "object") {
|
|
res.writeHead(503);
|
|
return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" }));
|
|
}
|
|
whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() };
|
|
return res.end(JSON.stringify(whoisResult, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
// Feature 28b: Company enrichment via Wikipedia + website meta scraping
|
|
if (reqPath === "/api/enrich") {
|
|
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, "");
|
|
const companyName = (url.searchParams.get("name") || "").trim();
|
|
const website = (url.searchParams.get("website") || "").trim();
|
|
const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";
|
|
|
|
let description = null;
|
|
let wikiUrl = null;
|
|
|
|
try {
|
|
// 1. Direct Wikipedia lookup by company name
|
|
if (companyName) {
|
|
const nameLower = companyName.toLowerCase();
|
|
const isRelevant = (title, extract) => {
|
|
const titleLower = (title || "").toLowerCase();
|
|
const extractLower = (extract || "").toLowerCase();
|
|
const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
|
|
const titleMatch = nameWords.some(w => titleLower.includes(w));
|
|
const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
|
|
const hasNetContext = netTerms.some(t => extractLower.includes(t));
|
|
return titleMatch && (hasNetContext || titleMatch);
|
|
};
|
|
|
|
// Direct title lookup — try full name first, then first word as fallback
|
|
const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
|
|
const firstName = cleanName.split(/\s+/)[0];
|
|
const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
|
|
for (const tryName of namesToTry) {
|
|
const wikiDirect = await fetchJSON(
|
|
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
|
|
{ timeout: 5000 }
|
|
);
|
|
if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
|
|
if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
|
|
description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 2. Wikipedia search if direct lookup didn't match
|
|
if (!description) {
|
|
const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
|
|
const searchData = await fetchJSON(
|
|
"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
|
|
{ timeout: 5000 }
|
|
);
|
|
if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
|
|
const topTitle = searchData[1][0];
|
|
const wikiSearch = await fetchJSON(
|
|
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
|
|
{ timeout: 5000 }
|
|
);
|
|
if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
|
|
if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
|
|
description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: scrape website meta description (follows up to 3 redirects)
|
|
function fetchPage(pageUrl, hops) {
|
|
if (hops <= 0) return Promise.resolve(null);
|
|
return new Promise((resolve) => {
|
|
const mod = pageUrl.startsWith("https") ? https : http;
|
|
const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
res.resume(); // drain to free socket
|
|
const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
|
|
return resolve(fetchPage(next, hops - 1));
|
|
}
|
|
let data = "";
|
|
res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
|
|
res.on("end", () => resolve(data));
|
|
});
|
|
req.on("error", () => resolve(null));
|
|
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
});
|
|
}
|
|
if (!description && website) {
|
|
let wsUrl = website;
|
|
if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
|
|
const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
|
|
const tryUrls = [aboutUrl, wsUrl];
|
|
for (const tryUrl of tryUrls) {
|
|
const resp = await fetchPage(tryUrl, 3);
|
|
if (!resp) continue;
|
|
const metaPatterns = [
|
|
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
|
|
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
|
|
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
|
|
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
|
|
];
|
|
let matched = false;
|
|
for (const pat of metaPatterns) {
|
|
const m = resp.match(pat);
|
|
if (m && m[1]) {
|
|
description = m[1].replace(/ |✓|&/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
matched = true;
|
|
break;
|
|
}
|
|
}
|
|
if (matched) break;
|
|
}
|
|
}
|
|
} catch (_e) {
|
|
// Return whatever we have
|
|
}
|
|
|
|
return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
|
|
}
|
|
|
|
// Feature 28: Submarine Cable overlay (TeleGeography proxy)
|
|
// ── Submarine Cables map data ─────────────────────────────────
|
|
if (reqPath === '/api/submarine-cables') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
|
|
if (reqPath === '/api/global-infra') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
|
|
// ── Changelog page ─────────────────────────────────────────
|
|
if (reqPath === '/changelog') {
|
|
try {
|
|
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
|
|
const lines = md.split('\n');
|
|
let html = '';
|
|
for (const line of lines) {
|
|
if (line.startsWith('## ')) {
|
|
html += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
|
|
} else if (line.startsWith('### ')) {
|
|
html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
|
|
} else if (line.startsWith('- **')) {
|
|
const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
|
|
html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
|
|
} else if (line.startsWith('- ')) {
|
|
html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
|
|
} else if (line.startsWith('# ')) {
|
|
html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
|
|
}
|
|
}
|
|
const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
|
|
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
|
|
a{color:var(--purple);text-decoration:none}
|
|
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
|
|
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
|
|
</style></head><body>
|
|
<a href="/" class="back">← peercortex.org</a>
|
|
${html}
|
|
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
|
|
</body></html>`;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.writeHead(200);
|
|
return res.end(page);
|
|
} catch(e) {
|
|
res.writeHead(500); return res.end('Changelog not available');
|
|
}
|
|
}
|
|
|
|
// ── BGP Community Decoder ────────────────────────────────────
|
|
if (reqPath === '/api/communities') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── IRR Audit ─────────────────────────────────────────────────
|
|
if (reqPath === '/api/irr-audit') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── AS-SET Expander ───────────────────────────────────────────
|
|
if (reqPath === '/api/asset-expand') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── Routing History (prefix table via RIPE Stat routing-history) ──
|
|
if (reqPath === '/api/rpki-history') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
|
|
if (reqPath === '/api/aspath') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
|
|
if (reqPath === '/api/looking-glass') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── IXP Peering Matrix ────────────────────────────────────────
|
|
if (reqPath === '/api/ix-matrix') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
|
|
// ── CORS preflight for all /api/webhooks + /api/hijacks ──────
|
|
if ((reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe') && req.method === 'OPTIONS') {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.writeHead(204); return res.end();
|
|
}
|
|
|
|
// ── Webhook: Register ─────────────────────────────────────────
|
|
// POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
|
|
if (reqPath === '/api/webhooks' && req.method === 'POST') {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
|
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); }
|
|
let body = '';
|
|
req.on('data', c => body += c);
|
|
req.on('end', () => {
|
|
try {
|
|
const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}');
|
|
if (!endpoint_url || !endpoint_url.startsWith('http')) {
|
|
res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'}));
|
|
}
|
|
const subs = loadWebhookSubs();
|
|
const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url);
|
|
if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); }
|
|
const webhookToken = crypto.randomBytes(32).toString('hex');
|
|
const entry = {
|
|
id: crypto.randomBytes(8).toString('hex'),
|
|
asn, endpoint_url,
|
|
secret: webhookToken,
|
|
timeout_ms: timeout_ms || 10000,
|
|
max_retries: max_retries || 5,
|
|
active: true,
|
|
created_at: new Date().toISOString(),
|
|
last_triggered_at: null,
|
|
failure_count: 0
|
|
};
|
|
subs.push(entry);
|
|
saveWebhookSubs(subs);
|
|
// Also ensure this ASN is in hijack monitoring
|
|
const hijackSubs = loadHijackSubs();
|
|
if (!hijackSubs.find(s => s.asn === asn)) {
|
|
checkHijacksForAsn(asn).then(prefixes => {
|
|
// null (lookup failed) becomes [] here so this record's shape stays
|
|
// consistent; runHijackCheck retries the real baseline next cycle.
|
|
hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
|
saveHijackSubs(hijackSubs);
|
|
});
|
|
}
|
|
res.writeHead(201);
|
|
res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' }));
|
|
} catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); }
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Webhook: List ─────────────────────────────────────────────
|
|
// GET /api/webhooks?asn=X
|
|
if (reqPath === '/api/webhooks' && req.method === 'GET') {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
|
const subs = loadWebhookSubs();
|
|
const result = (asn ? subs.filter(s => s.asn === asn) : subs)
|
|
.map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at }));
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify({ webhooks: result, total: result.length }));
|
|
}
|
|
|
|
// ── Webhook: Delete ───────────────────────────────────────────
|
|
// DELETE /api/webhooks/:id
|
|
if (reqPath.startsWith('/api/webhooks/') && req.method === 'DELETE' && !reqPath.includes('/test')) {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
const id = reqPath.split('/')[3];
|
|
const subs = loadWebhookSubs();
|
|
const idx = subs.findIndex(s => s.id === id);
|
|
if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
|
|
subs.splice(idx, 1);
|
|
saveWebhookSubs(subs);
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify({ deleted: true, id }));
|
|
}
|
|
|
|
// ── Webhook: Test ─────────────────────────────────────────────
|
|
// POST /api/webhooks/:id/test
|
|
if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === 'POST') {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
const id = reqPath.split('/')[3];
|
|
const subs = loadWebhookSubs();
|
|
const sub = subs.find(s => s.id === id);
|
|
if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
|
|
const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() };
|
|
const start = Date.now();
|
|
deliverWebhook(sub, testPayload, 1).then(result => {
|
|
res.writeHead(result.ok ? 200 : 502);
|
|
res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Hijack Events: List ───────────────────────────────────────
|
|
// GET /api/hijacks?asn=X&limit=50&resolved=false
|
|
if (reqPath === '/api/hijacks' && req.method === 'GET') {
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
|
const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);
|
|
const resolvedFilter = params.get('resolved');
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
let events = loadHijackAlerts();
|
|
if (asn) events = events.filter(a => String(a.asn) === asn);
|
|
if (resolvedFilter === 'false') events = events.filter(a => !a.resolved);
|
|
if (resolvedFilter === 'true') events = events.filter(a => a.resolved);
|
|
const total = events.length;
|
|
events = events.slice(-limit).reverse();
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify({ total, events }));
|
|
}
|
|
|
|
// ── Hijack Events: Resolve ────────────────────────────────────
|
|
// POST /api/hijacks/:id/resolve body: { resolution_notes? }
|
|
if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === 'POST') {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
const id = reqPath.split('/')[3];
|
|
let body = '';
|
|
req.on('data', c => body += c);
|
|
req.on('end', () => {
|
|
const alerts = loadHijackAlerts();
|
|
const alert = alerts.find(a => a.id === id);
|
|
if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); }
|
|
alert.resolved = true;
|
|
alert.resolved_at = new Date().toISOString();
|
|
try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){}
|
|
saveHijackAlerts(alerts);
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at }));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Hijack Subscribe (legacy, kept for backwards compatibility) ─
|
|
if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
let body = '';
|
|
req.on('data', c => body += c);
|
|
req.on('end', async () => {
|
|
try {
|
|
const { asn } = JSON.parse(body || '{}');
|
|
const asnNum = String(asn || '').replace(/[^0-9]/g,'');
|
|
if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
|
|
const subs = loadHijackSubs();
|
|
const exists = subs.find(s => s.asn === asnNum);
|
|
if (!exists) {
|
|
const prefixes = await checkHijacksForAsn(asnNum);
|
|
// prefixes may be null if the lookup failed just now -- store an empty
|
|
// array so this record shape stays consistent; runHijackCheck's own
|
|
// "!sub.prefixes.length" check will retry establishing the real
|
|
// baseline on its next cycle rather than treating this as permanent.
|
|
subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
|
saveHijackSubs(subs);
|
|
}
|
|
const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
|
|
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ── Hijack Alerts (legacy endpoint, kept for backwards compatibility) ─
|
|
if (reqPath.startsWith('/api/hijack-alerts')) {
|
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
const allAlerts = loadHijackAlerts();
|
|
const alerts = asn ? allAlerts.filter(a => String(a.asn) === asn) : allAlerts;
|
|
const subs = loadHijackSubs();
|
|
const sub = subs.find(s => s.asn === asn);
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 }));
|
|
}
|
|
|
|
|
|
// ── Hijack Alerts (legacy read) ───────────────────────────────
|
|
// src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under
|
|
// the same paths this file already serves above (file-backed, live).
|
|
// Deliberately NOT proxied here — that would shadow working data with an
|
|
// empty Postgres-backed implementation. Not a gap, just two feature
|
|
// branches that ended up naming their routes the same thing.
|
|
|
|
// ── Changelog JSON API ────────────────────────────────────────
|
|
if (reqPath === '/changelog-data') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── bio-rd RIB routes ─────────────────────────────────────────
|
|
if (reqPath.startsWith('/api/rib/')) {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ── Prefix Changes ──────────────────────────────────────────────
|
|
if (reqPath === '/api/prefix-changes') {
|
|
return proxyToApiServer(req, res, req.url);
|
|
}
|
|
|
|
// ============================================================
|
|
// FEATURE 2: PDF Export
|
|
// GET /api/export/pdf?asn=X&format=report|executive|technical
|
|
// GET /api/export/pdf/compare?asn1=X&asn2=Y
|
|
// ============================================================
|
|
|
|
// Helper: fetch JSON from internal endpoint
|
|
async function fetchInternal(path) {
|
|
return new Promise((resolve, reject) => {
|
|
const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
|
|
const req2 = http.request(opts, res2 => {
|
|
let body = '';
|
|
res2.on('data', c => body += c);
|
|
res2.on('end', () => {
|
|
try { resolve(JSON.parse(body)); }
|
|
catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
|
|
});
|
|
});
|
|
req2.on('error', reject);
|
|
req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
|
|
req2.end();
|
|
});
|
|
}
|
|
|
|
if (reqPath === '/api/export/pdf' && req.method === 'GET') {
|
|
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
|
|
const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
|
|
? url.searchParams.get('format') : 'report';
|
|
if (!rawAsn) {
|
|
res.writeHead(400, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
|
|
}
|
|
const cacheKey = `pdf:${rawAsn}:${format}`;
|
|
const cached = pdfCacheGet(cacheKey);
|
|
if (cached) {
|
|
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
|
|
return res.end(cached);
|
|
}
|
|
try {
|
|
const [validateData, aspaData] = await Promise.all([
|
|
fetchInternal(`/api/validate?asn=${rawAsn}`),
|
|
fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
|
|
]);
|
|
if (validateData.error) {
|
|
res.writeHead(400, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: validateData.error }));
|
|
}
|
|
const html = buildPdfHtml(validateData, aspaData, format);
|
|
const pdfBuf = await renderHtmlToPdf(html);
|
|
pdfCacheSet(cacheKey, pdfBuf);
|
|
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
|
|
return res.end(pdfBuf);
|
|
} catch (err) {
|
|
console.error('[PDF] Error:', err.message);
|
|
res.writeHead(503, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
|
|
}
|
|
}
|
|
|
|
if (reqPath === '/api/export/pdf/compare' && req.method === 'GET') {
|
|
const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
|
|
const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
|
|
if (!asn1 || !asn2) {
|
|
res.writeHead(400, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
|
|
}
|
|
if (asn1 === asn2) {
|
|
res.writeHead(400, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
|
|
}
|
|
const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
|
|
const cached = pdfCacheGet(cacheKey);
|
|
if (cached) {
|
|
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
|
|
return res.end(cached);
|
|
}
|
|
try {
|
|
const [v1, a1, l1, v2, a2, l2] = await Promise.all([
|
|
fetchInternal(`/api/validate?asn=${asn1}`),
|
|
fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
|
|
fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
|
|
fetchInternal(`/api/validate?asn=${asn2}`),
|
|
fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
|
|
fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
|
|
]);
|
|
if (v1.error || v2.error) {
|
|
res.writeHead(400, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: v1.error || v2.error }));
|
|
}
|
|
const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
|
|
const pdfBuf = await renderHtmlToPdf(html);
|
|
pdfCacheSet(cacheKey, pdfBuf);
|
|
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
|
|
return res.end(pdfBuf);
|
|
} catch (err) {
|
|
console.error('[PDF] Error:', err.message);
|
|
res.writeHead(503, {'Content-Type':'application/json'});
|
|
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
|
|
}
|
|
}
|
|
|
|
// CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
|
|
if (reqPath.startsWith('/api/export/') && req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
return res.end();
|
|
}
|
|
|
|
// ============================================================
|
|
// FEATURE 3: ASPA Adoption Tracker — API + Dashboard
|
|
// GET /aspa-adoption → dashboard HTML
|
|
// GET /api/aspa-adoption-stats?period=7d|30d|1y → JSON trend
|
|
// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
|
|
// ============================================================
|
|
|
|
if (reqPath === '/aspa-adoption') {
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.writeHead(200);
|
|
return res.end(buildAspaAdoptionDashboard());
|
|
}
|
|
|
|
if (reqPath === '/api/aspa-adoption-stats' && req.method === 'GET') {
|
|
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
|
? url.searchParams.get('period') : '30d';
|
|
const trend = getAdoptionTrend(period);
|
|
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
|
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
|
|
|
|
// Linear regression forecast
|
|
function forecast(data, daysAhead) {
|
|
if (data.length < 2) return null;
|
|
const n = data.length;
|
|
const xs = data.map((_, i) => i);
|
|
const ys = data.map(d => d.coverage_pct_atlas);
|
|
const xMean = xs.reduce((a,b)=>a+b,0)/n;
|
|
const yMean = ys.reduce((a,b)=>a+b,0)/n;
|
|
const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
|
|
const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
|
|
if (den===0) return yMean;
|
|
const slope = num/den;
|
|
return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
|
|
}
|
|
|
|
const trend30 = getAdoptionTrend('30d');
|
|
const result = {
|
|
period,
|
|
current: {
|
|
date: latest?.date,
|
|
aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
|
|
roa_count: latest?.roa_count,
|
|
atlas_asns_total: latest?.atlas_asns_total ?? 0,
|
|
atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
|
|
coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
|
|
delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
|
|
},
|
|
trend: trend.map(s => ({
|
|
date: s.date,
|
|
aspa_objects: s.aspa_objects,
|
|
coverage_pct_atlas: s.coverage_pct_atlas,
|
|
atlas_asns_total: s.atlas_asns_total,
|
|
})),
|
|
forecast: {
|
|
predicted_90d: forecast(trend30, 90),
|
|
confidence: trend30.length >= 10 ? 0.75 : 0.5,
|
|
method: 'linear_regression',
|
|
based_on_samples: trend30.length,
|
|
},
|
|
meta: {
|
|
total_snapshots: aspaAdoptionDailyHistory.length,
|
|
last_updated: latest?.date ?? null,
|
|
denominator: 'atlas_visible_asns',
|
|
source: 'rpki_cloudflare_feed',
|
|
}
|
|
};
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify(result, null, 2));
|
|
}
|
|
|
|
if (reqPath === '/api/aspa-adoption-stats/export' && req.method === 'GET') {
|
|
const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
|
|
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
|
? url.searchParams.get('period') : '30d';
|
|
const data = getAdoptionTrend(period);
|
|
|
|
if (fmt === 'csv') {
|
|
const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
|
|
const rows = data.map(s =>
|
|
`${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
|
|
).join('\n');
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
|
|
res.writeHead(200);
|
|
return res.end(header + rows);
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
|
|
}
|
|
|
|
// GET /api/ipv6-adoption-stats?refresh=1
|
|
if (reqPath === '/api/ipv6-adoption-stats' && req.method === 'GET') {
|
|
const force = url.searchParams.get('refresh') === '1';
|
|
try {
|
|
const data = await fetchIpv6AdoptionStats(force);
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.writeHead(200);
|
|
return res.end(JSON.stringify(data, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(503);
|
|
return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
|
|
}
|
|
}
|
|
|
|
// 404
|
|
res.writeHead(404);
|
|
res.end(
|
|
JSON.stringify({
|
|
error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y, /api/aspa-adoption-stats?period=30d, /api/ipv6-adoption-stats",
|
|
})
|
|
);
|
|
});
|
|
|
|
|
|
const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes");
|
|
|
|
const { pdbOrgState, fetchPdbOrgCountries } = require("./server/pdb-org-countries");
|
|
|
|
const PORT = process.env.PORT || 3101;
|
|
|
|
// ============================================================
|
|
// Startup Sequence — load disk caches first, then fetch fresh data
|
|
// ============================================================
|
|
|
|
// Phase 0: Load disk caches for fast restart (instant)
|
|
roaStore.loadFromDisk("/opt/peercortex-app/.roa-cache.json");
|
|
pdbSourceCache.loadFromDisk("/opt/peercortex-app/.pdb-source-cache.json");
|
|
loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");
|
|
|
|
// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants
|
|
ensureManrsCache(); // fire-and-forget, 24h cache
|
|
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
|
|
// Re-record ASPA adoption snapshot now that Atlas data is fully loaded
|
|
recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap);
|
|
|
|
server.listen(PORT, "0.0.0.0", () => {
|
|
console.log("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT);
|
|
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
|
|
console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured"));
|
|
console.log("RPKI ASPA objects: " + rpkiAspaMap.size);
|
|
console.log("ROA store: " + roaStore.count + " entries (" + (roaStore.ready ? "ready" : "loading...") + ")");
|
|
console.log("PDB source cache: net=" + pdbSourceCache.net.size + " ix=" + pdbSourceCache.netixlan.size + " fac=" + pdbSourceCache.netfac.size);
|
|
console.log("RIPE Stat cache: " + ripeStatCache.size + " entries");
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// bio-rd RIB WebSocket — live route streaming on /ws/rib
|
|
// ============================================================
|
|
let WebSocketServer = null;
|
|
try { WebSocketServer = require('ws').Server; } catch(_e) {}
|
|
|
|
if (WebSocketServer) {
|
|
const ribWss = new WebSocketServer({ server, path: '/ws/rib' });
|
|
ribWss.on('connection', function(ws) {
|
|
let cancelStream = null;
|
|
|
|
ws.on('message', function(raw) {
|
|
try {
|
|
const msg = JSON.parse(raw);
|
|
if (msg.type === 'rib-subscribe') {
|
|
if (cancelStream) { cancelStream(); cancelStream = null; }
|
|
if (!risClient) {
|
|
ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' }));
|
|
return;
|
|
}
|
|
const router = msg.router || 'default';
|
|
cancelStream = risClient.observeRib(
|
|
router,
|
|
'default',
|
|
function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); },
|
|
function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); }
|
|
);
|
|
}
|
|
} catch(e) {
|
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message }));
|
|
}
|
|
});
|
|
|
|
ws.on('close', function() {
|
|
if (cancelStream) { cancelStream(); cancelStream = null; }
|
|
});
|
|
});
|
|
console.log('[bio-rd] RIB WebSocket server listening on /ws/rib');
|
|
} else {
|
|
console.log('[bio-rd] WebSocket server skipped (ws package not installed)');
|
|
}
|
|
|
|
// ============================================================
|
|
// Refresh timers — jittered to avoid thundering herd
|
|
// ============================================================
|
|
|
|
// RPKI feed (ASPA + ROA): every 4h ± 5min jitter
|
|
setInterval(() => {
|
|
fetchRpkiAspaFeed();
|
|
}, 4 * 60 * 60 * 1000 + Math.floor(Math.random() * 10 * 60 * 1000) - 5 * 60 * 1000);
|
|
|
|
// Atlas probe cache: every 12h ± 10min jitter
|
|
setInterval(function() {
|
|
fetchAllAtlasProbes();
|
|
}, 12 * 60 * 60 * 1000 + Math.floor(Math.random() * 20 * 60 * 1000) - 10 * 60 * 1000);
|
|
|
|
// Disk cache persistence: every 30 minutes
|
|
setInterval(function() {
|
|
pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
|
|
saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
|
|
}, 30 * 60 * 1000);
|
|
|
|
// Save caches on graceful shutdown
|
|
process.on("SIGTERM", function() {
|
|
console.log("[SHUTDOWN] Saving caches to disk...");
|
|
pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
|
|
saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
|
|
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
|
|
process.exit(0);
|
|
});
|
|
process.on("SIGINT", function() {
|
|
console.log("[SHUTDOWN] Saving caches to disk...");
|
|
pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json");
|
|
saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json");
|
|
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
|
|
process.exit(0);
|
|
});
|