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