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).
209 lines
10 KiB
JavaScript
209 lines
10 KiB
JavaScript
const { fetchRipeStatCached } = require("../../services/ripe-stat");
|
|
const { fetchPeeringDB } = require("../../services/peeringdb");
|
|
const { ensureAspaCache } = require("../../services/rpki");
|
|
const { validateResultCache, resultCacheGet, resultCacheSet, RESULT_CACHE_TTL } = require("../../caches/response-caches");
|
|
const { computeBogonResult } = require("./bogon");
|
|
const checks = require("./checks");
|
|
|
|
// Calculate overall health score (0-100)
|
|
const SCORE_WEIGHTS = [
|
|
{ key: "bogon", weight: 15 },
|
|
{ key: "irr", weight: 10 },
|
|
{ key: "rpki_completeness", weight: 15 },
|
|
{ key: "abuse_contact", weight: 5 },
|
|
{ key: "blocklist", weight: 15 },
|
|
{ key: "manrs", weight: 5 },
|
|
{ key: "rdns", weight: 5 },
|
|
{ key: "visibility", weight: 10 },
|
|
{ key: "rpsl", weight: 5 },
|
|
{ key: "ix_route_server", weight: 5 },
|
|
{ key: "resource_cert", weight: 10 },
|
|
];
|
|
|
|
function scoreChecks(validations) {
|
|
var totalWeight = 0;
|
|
var earnedScore = 0;
|
|
var checkResults = [];
|
|
|
|
SCORE_WEIGHTS.forEach(function(c) {
|
|
var v = validations[c.key];
|
|
var points = 0;
|
|
if (v && v.status === "info") {
|
|
// "info" = unable to verify (e.g. API auth required) — exclude from scoring
|
|
checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" });
|
|
return;
|
|
}
|
|
if (v && v.status === "pass") points = c.weight;
|
|
else if (v && v.status === "warning") points = Math.round(c.weight * 0.5);
|
|
totalWeight += c.weight;
|
|
earnedScore += points;
|
|
checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" });
|
|
});
|
|
|
|
var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;
|
|
return { healthScore: healthScore, checkResults: checkResults };
|
|
}
|
|
|
|
// Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks)
|
|
function enrichGeolocation(validations, facCountries, net) {
|
|
if (!validations.geolocation || validations.geolocation.status === "error") return;
|
|
var uniqueFacCountries = {};
|
|
facCountries.forEach(function(c) { uniqueFacCountries[c] = true; });
|
|
var facCountryCount = Object.keys(uniqueFacCountries).length;
|
|
validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries);
|
|
var geoSet = {};
|
|
(validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; });
|
|
var geoCountryCount = Object.keys(geoSet).length;
|
|
var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; });
|
|
validations.geolocation.country_mismatches = mismatches;
|
|
|
|
// Detect global/anycast networks: 5+ facility countries OR Content/NSP type
|
|
var netInfoType = (net.info_type || "").toLowerCase();
|
|
var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp";
|
|
if (isGlobalNetwork) {
|
|
// Global/anycast/CDN network: geo mismatches are expected, not anomalies
|
|
validations.geolocation.status = "pass";
|
|
if (geoCountryCount === 0) {
|
|
validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available";
|
|
} else {
|
|
validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")";
|
|
}
|
|
validations.geolocation.country_mismatches = [];
|
|
} else if (facCountryCount <= 2 && geoCountryCount >= 10) {
|
|
// Actual anomaly: small network appearing in many countries
|
|
validations.geolocation.status = "warning";
|
|
validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration";
|
|
}
|
|
}
|
|
|
|
// Build relationships from neighbour data
|
|
function buildRelationships(neighbourData) {
|
|
var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts
|
|
? neighbourData.data.neighbour_counts : {};
|
|
var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours
|
|
? neighbourData.data.neighbours : [];
|
|
var relUpstreams = relList.filter(function(n) { return n.type === "left"; })
|
|
.sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
|
|
.slice(0, 20)
|
|
.map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
|
|
var relDownstreams = relList.filter(function(n) { return n.type === "right"; })
|
|
.sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
|
|
.slice(0, 20)
|
|
.map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
|
|
var relPeers = relList.filter(function(n) { return n.type === "uncertain"; })
|
|
.sort(function(a, b) { return (b.power || 0) - (a.power || 0); })
|
|
.slice(0, 30)
|
|
.map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; });
|
|
|
|
return {
|
|
counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 },
|
|
upstreams: relUpstreams,
|
|
downstreams: relDownstreams,
|
|
top_peers: relPeers,
|
|
source: "RIPE Stat asn-neighbours",
|
|
note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.",
|
|
};
|
|
}
|
|
|
|
// Unified Validation endpoint: /api/validate?asn=X
|
|
// Runs ALL validations in parallel, returns comprehensive report
|
|
async function handleValidate(req, res, url) {
|
|
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 cachedValidate = resultCacheGet(validateResultCache, rawAsn);
|
|
if (cachedValidate !== undefined) {
|
|
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
|
return res.end(JSON.stringify(cachedValidate));
|
|
}
|
|
const start = Date.now();
|
|
const targetAsn = parseInt(rawAsn);
|
|
|
|
try {
|
|
// Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2
|
|
const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([
|
|
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
|
fetchPeeringDB("/net?asn=" + rawAsn),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
|
]);
|
|
|
|
const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; });
|
|
// Use all prefixes for RPKI validation (local lookup is fast, no API calls)
|
|
const samplePrefixes = allPrefixes;
|
|
const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {};
|
|
const netId = net.id;
|
|
const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : [];
|
|
|
|
const bogonResult = computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData);
|
|
|
|
// Phase 2: All API-dependent validations in parallel
|
|
await ensureAspaCache(); // Ensure ROA data is loaded before RPKI-dependent checks
|
|
var validationPromises = {
|
|
irr: checks.checkIRR(rawAsn),
|
|
rpki_completeness: checks.checkRpkiCompleteness(rawAsn, allPrefixes),
|
|
abuse_contact: checks.checkAbuseContact(rawAsn),
|
|
blocklist: checks.checkBlocklist(samplePrefixes),
|
|
manrs: checks.checkManrs(rawAsn),
|
|
rdns: checks.checkRdns(samplePrefixes),
|
|
visibility: checks.checkVisibility(rawAsn, samplePrefixes),
|
|
communities: checks.checkCommunities(samplePrefixes),
|
|
geolocation: checks.checkGeolocation(samplePrefixes),
|
|
rpsl: checks.checkRpsl(rawAsn),
|
|
ix_route_server: checks.checkIxRouteServer(rawAsn),
|
|
resource_cert: checks.checkResourceCert(rawAsn, allPrefixes),
|
|
};
|
|
|
|
const facCountriesPromise = checks.fetchFacCountries(netId);
|
|
|
|
// Run all validations in parallel — 5s cap per check, total validate bounded to ~10s
|
|
var keys = Object.keys(validationPromises);
|
|
var promises = keys.map(function(k) {
|
|
return Promise.race([
|
|
validationPromises[k],
|
|
new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }),
|
|
]);
|
|
});
|
|
var settled = await Promise.allSettled(promises);
|
|
var facCountries = await facCountriesPromise;
|
|
|
|
var validations = {};
|
|
keys.forEach(function(key, i) {
|
|
if (settled[i].status === "fulfilled") {
|
|
validations[key] = settled[i].value;
|
|
} else {
|
|
validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" };
|
|
}
|
|
});
|
|
|
|
enrichGeolocation(validations, facCountries, net);
|
|
validations.bogon = bogonResult;
|
|
|
|
const { healthScore, checkResults } = scoreChecks(validations);
|
|
const duration = Date.now() - start;
|
|
const relationships = buildRelationships(neighbourData);
|
|
|
|
const validateResult = {
|
|
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length },
|
|
asn: targetAsn,
|
|
name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown",
|
|
health_score: healthScore,
|
|
score_breakdown: checkResults,
|
|
validations: validations,
|
|
relationships: relationships,
|
|
};
|
|
// Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures
|
|
// Full results with prefixes are cached for the standard 15 minutes
|
|
const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL;
|
|
resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL);
|
|
return res.end(JSON.stringify(validateResult, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "Validation failed", message: err.message }));
|
|
}
|
|
}
|
|
|
|
module.exports = { handleValidate };
|