refactor: decompose /api/validate into server/routes/validate/{checks,bogon,index}.js
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).
This commit is contained in:
parent
601427b4af
commit
144457f5d2
521
server.js
521
server.js
@ -225,6 +225,7 @@ 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", "*");
|
||||
@ -333,529 +334,11 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Unified Validation endpoint: /api/validate?asn=X
|
||||
// Runs ALL validations in parallel, returns comprehensive report
|
||||
// ============================================================
|
||||
if (reqPath === "/api/validate") {
|
||||
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 : [];
|
||||
|
||||
// ---- 11. Bogon Detection (local check) ----
|
||||
function checkBogonPrefix(prefix) {
|
||||
var bogonV4 = [
|
||||
{ net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 },
|
||||
{ net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 },
|
||||
{ net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 },
|
||||
{ net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 },
|
||||
{ net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 },
|
||||
{ net: "240.0.0.0", mask: 4 },
|
||||
];
|
||||
if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" };
|
||||
var split = prefix.split("/");
|
||||
var addr = split[0];
|
||||
var mask = parseInt(split[1] || "0");
|
||||
var parts = addr.split(".").map(Number);
|
||||
var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
for (var bi = 0; bi < bogonV4.length; bi++) {
|
||||
var b = bogonV4[bi];
|
||||
var bParts = b.net.split(".").map(Number);
|
||||
var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0;
|
||||
var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0;
|
||||
if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) {
|
||||
return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask };
|
||||
}
|
||||
}
|
||||
return { prefix: prefix, is_bogon: false };
|
||||
return validateRoute.handleValidate(req, res, url);
|
||||
}
|
||||
|
||||
function checkBogonAsn(asnNum) {
|
||||
if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true;
|
||||
if (asnNum >= 64496 && asnNum <= 64511) return true;
|
||||
if (asnNum >= 64512 && asnNum <= 65534) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
var bogonPrefixResults = allPrefixes.map(checkBogonPrefix);
|
||||
var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
|
||||
var asnInPaths = neighbours.map(function(n) { return n.asn; });
|
||||
var bogonAsns = asnInPaths.filter(checkBogonAsn);
|
||||
// "pass" must mean prefixes/neighbours were actually fetched and checked clean --
|
||||
// not that the upstream source failed and left nothing to check. A source is
|
||||
// unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a
|
||||
// truthy object with status:"error" (local-db-client.js's DB-error signal --
|
||||
// see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check
|
||||
// alone misses this case and would show "pass" on 0 checked prefixes).
|
||||
function isSourceUnavailable(x) { return !x || x.status === "error"; }
|
||||
var prefixesUnavailable = isSourceUnavailable(prefixData);
|
||||
var neighboursUnavailable = isSourceUnavailable(neighbourData);
|
||||
var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable;
|
||||
var bogonResult = {
|
||||
status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"),
|
||||
bogon_prefixes: bogonPrefixes,
|
||||
bogon_asns_in_paths: bogonAsns,
|
||||
total_prefixes_checked: allPrefixes.length,
|
||||
prefixes_source_unavailable: prefixesUnavailable,
|
||||
neighbours_source_unavailable: neighboursUnavailable,
|
||||
};
|
||||
|
||||
// Phase 2: All API-dependent validations in parallel
|
||||
var validationPromises = {};
|
||||
|
||||
// 12. IRR Validation
|
||||
// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than
|
||||
// rejecting, so the .catch() below never sees the common failure mode -- a
|
||||
// dead upstream would otherwise silently read as "0 entries, no mismatches, pass".
|
||||
validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
|
||||
if (irrData === null) {
|
||||
return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 };
|
||||
}
|
||||
var entries = Array.isArray(irrData) ? irrData : [];
|
||||
var mismatches = entries.filter(function(e) {
|
||||
if (!e.bgpOrigins && !e.bgp_origins) return false;
|
||||
if (!e.irrRoutes && !e.irr_origins) return false;
|
||||
var bgpArr = e.bgpOrigins || e.bgp_origins || [];
|
||||
var irrArr = e.irrRoutes || e.irr_origins || [];
|
||||
var bgpSet = {};
|
||||
bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; });
|
||||
var match = false;
|
||||
irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; });
|
||||
return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match;
|
||||
});
|
||||
return {
|
||||
status: mismatches.length === 0 ? "pass" : "warning",
|
||||
total_entries: entries.length,
|
||||
mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }),
|
||||
mismatch_count: mismatches.length,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs)
|
||||
await ensureAspaCache(); // Ensure ROA data is loaded
|
||||
validationPromises.rpki_completeness = Promise.all(
|
||||
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
|
||||
).then(function(rpkiResults) {
|
||||
var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
|
||||
var unavailableCount = rpkiResults.length - checkedResults.length;
|
||||
var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; });
|
||||
var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0;
|
||||
var overSpecific = checkedResults.filter(function(r) {
|
||||
var mask = parseInt((r.prefix || "").split("/")[1] || "0");
|
||||
return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
|
||||
});
|
||||
// If the local RPKI DB was unreachable for every prefix, we haven't actually
|
||||
// checked anything -- say so instead of reporting a misleading pass/fail.
|
||||
var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
|
||||
coverage_pct: coverage,
|
||||
total_checked: checkedResults.length,
|
||||
db_unavailable_count: unavailableCount,
|
||||
with_roa: withRoa.length,
|
||||
over_specific: overSpecific.map(function(r) { return r.prefix; }),
|
||||
details: rpkiResults,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 14. Abuse Contact Validation
|
||||
validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
|
||||
if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false };
|
||||
var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
|
||||
var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
|
||||
return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 15. Spamhaus DROP/Blocklist
|
||||
validationPromises.blocklist = Promise.all(
|
||||
samplePrefixes.slice(0, 5).map(function(pfx) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
|
||||
// fetchRipeStatCached resolves(null) on timeout/network-error rather than
|
||||
// throwing, so the .catch() below never sees that case -- distinguish it
|
||||
// explicitly instead of letting a failed lookup read as "not listed".
|
||||
if (data === null) return { prefix: pfx, listed: false, checked: false };
|
||||
var sources = data && data.data && data.data.sources ? data.data.sources : [];
|
||||
var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
|
||||
return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
|
||||
}).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
|
||||
})
|
||||
).then(function(results) {
|
||||
var checkedResults = results.filter(function(r) { return r.checked; });
|
||||
var listedPrefixes = checkedResults.filter(function(r) { return r.listed; });
|
||||
var allUnchecked = results.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"),
|
||||
checked: checkedResults.length,
|
||||
unavailable: results.length - checkedResults.length,
|
||||
listed_prefixes: listedPrefixes,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 16. MANRS Compliance — scraped from public participants list (24h cache)
|
||||
validationPromises.manrs = ensureManrsCache().then(function() {
|
||||
return checkManrsMembership(rawAsn);
|
||||
}).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; });
|
||||
|
||||
// 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs)
|
||||
var rdnsSampleSize = Math.min(3, samplePrefixes.length);
|
||||
validationPromises.rdns = Promise.all(
|
||||
samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
|
||||
if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true };
|
||||
var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
|
||||
var hasDelegation = false;
|
||||
var details = [];
|
||||
// API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } }
|
||||
["ipv4", "ipv6"].forEach(function(af) {
|
||||
var afData = pfxData[af] || {};
|
||||
Object.keys(afData).forEach(function(p) {
|
||||
var entry = afData[p];
|
||||
if (entry && entry.complete) hasDelegation = true;
|
||||
if (entry && entry.domains) {
|
||||
entry.domains.forEach(function(d) {
|
||||
if (d.found) hasDelegation = true;
|
||||
details.push({ domain: d.domain, found: !!d.found });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
// Fallback: old array format
|
||||
if (Array.isArray(pfxData)) {
|
||||
pfxData.forEach(function(p) {
|
||||
if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true;
|
||||
});
|
||||
}
|
||||
return { prefix: pfx, has_rdns: hasDelegation, details: details };
|
||||
}).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
|
||||
})
|
||||
).then(function(results) {
|
||||
var checkedResults = results.filter(function(r) { return !r.error; });
|
||||
var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
|
||||
var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
|
||||
var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; });
|
||||
var allUnavailable = results.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"),
|
||||
coverage_pct: coverage,
|
||||
checked: checkedResults.length,
|
||||
unavailable: results.length - checkedResults.length,
|
||||
results: results,
|
||||
failed_prefixes: failedPrefixes,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
|
||||
validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) {
|
||||
var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
|
||||
var v4 = vis.v4 || {};
|
||||
var v6 = vis.v6 || {};
|
||||
var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
|
||||
var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
|
||||
var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
|
||||
var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
|
||||
// If routing-status returned no data, try bgproutes.io
|
||||
if (totalPeers === 0 && samplePrefixes[0]) {
|
||||
return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
|
||||
if (bgprFb && bgprFb.vps_seeing > 0) {
|
||||
seeingPeers = bgprFb.vps_seeing;
|
||||
totalPeers = Math.max(bgprFb.vps_seeing, 300);
|
||||
score = Math.round((seeingPeers / totalPeers) * 100);
|
||||
}
|
||||
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
|
||||
}).catch(function() {
|
||||
// Neither RIPE routing-status nor the bgproutes.io fallback returned data --
|
||||
// this network hasn't actually been measured, not confirmed at 0% visibility.
|
||||
return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
|
||||
});
|
||||
}
|
||||
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 19. BGP Communities Analysis
|
||||
validationPromises.communities = (samplePrefixes.length > 0
|
||||
? (function() {
|
||||
var now = new Date();
|
||||
var end = now.toISOString().replace(/\.\d+Z/, "");
|
||||
var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, "");
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end);
|
||||
})()
|
||||
: Promise.resolve(null)
|
||||
).then(function(data) {
|
||||
var updates = data && data.data && data.data.updates ? data.data.updates : [];
|
||||
var communityMap = {};
|
||||
var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" };
|
||||
updates.forEach(function(u) {
|
||||
var attrs = u.attrs || {};
|
||||
var communities = attrs.community || [];
|
||||
communities.forEach(function(c) {
|
||||
var key = Array.isArray(c) ? c.join(":") : String(c);
|
||||
if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null };
|
||||
communityMap[key].count++;
|
||||
});
|
||||
});
|
||||
var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; });
|
||||
var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; });
|
||||
return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 20. Geolocation Verification
|
||||
validationPromises.geolocation = (samplePrefixes.length > 0
|
||||
? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0]))
|
||||
: Promise.resolve(null)
|
||||
).then(function(data) {
|
||||
var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : [];
|
||||
var countries = {};
|
||||
locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); });
|
||||
return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel)
|
||||
validationPromises.rpsl = (function() {
|
||||
// Try RIPE first (has richest policy data), then RDAP for other RIRs
|
||||
var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) {
|
||||
var objects = data && data.objects && data.objects.object ? data.objects.object : [];
|
||||
if (objects.length === 0) return null;
|
||||
var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : [];
|
||||
var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; });
|
||||
var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; });
|
||||
var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; });
|
||||
return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" };
|
||||
}).catch(function() { return null; });
|
||||
|
||||
var rdapEndpoints = [
|
||||
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn },
|
||||
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn },
|
||||
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn },
|
||||
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn },
|
||||
];
|
||||
var rdapPromises = rdapEndpoints.map(function(ep) {
|
||||
return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) {
|
||||
if (!data || data.errorCode || !data.handle) return null;
|
||||
var hasRemarks = !!(data.remarks && data.remarks.length > 0);
|
||||
var name = data.name || "";
|
||||
return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" };
|
||||
}).catch(function() { return null; });
|
||||
});
|
||||
|
||||
return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) {
|
||||
// Take first successful result
|
||||
for (var ri = 0; ri < results.length; ri++) {
|
||||
if (results[ri] !== null) return results[ri];
|
||||
}
|
||||
return { status: "warning", exists: false, has_policy: false };
|
||||
});
|
||||
})();
|
||||
|
||||
// 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering)
|
||||
// Always use asn= for netixlan (more reliable than net_id when PDB rate-limits)
|
||||
var ixRsQueryUrl = "/netixlan?asn=" + rawAsn;
|
||||
{
|
||||
validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) {
|
||||
var connections = ixData && ixData.data ? ixData.data : [];
|
||||
var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; });
|
||||
var totalIx = connections.length;
|
||||
var rsCount = rsParticipants.length;
|
||||
var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0;
|
||||
var status, note;
|
||||
|
||||
if (totalIx > 0 && rsCount > 0) {
|
||||
// Using route servers - good
|
||||
status = "pass";
|
||||
note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)";
|
||||
} else if (totalIx >= 10 && rsCount === 0) {
|
||||
// Network with 10+ IX connections but no RS = deliberate bilateral peering policy
|
||||
status = "pass";
|
||||
note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)";
|
||||
} else if (totalIx < 3 && rsCount === 0) {
|
||||
// Very small IX presence and no RS
|
||||
status = "warning";
|
||||
note = "Only " + totalIx + " IX connection(s) and no route server usage";
|
||||
} else {
|
||||
// Small-medium network (3-9 IX) without RS - informational
|
||||
status = "info";
|
||||
note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability";
|
||||
}
|
||||
|
||||
return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 23. Resource Certification (local RPKI validation - all prefixes, all RIRs)
|
||||
validationPromises.resource_cert = Promise.all(
|
||||
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
|
||||
).then(function(results) {
|
||||
var checked = results.filter(function(r) { return r.status !== "unavailable"; });
|
||||
var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
|
||||
var allUnavailable = results.length > 0 && checked.length === 0;
|
||||
return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
|
||||
// Geolocation cross-ref with PeeringDB facilities
|
||||
var facCountriesPromise = netId
|
||||
? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) {
|
||||
return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean);
|
||||
}).catch(function() { return []; })
|
||||
: Promise.resolve([]);
|
||||
|
||||
// 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" };
|
||||
}
|
||||
});
|
||||
|
||||
// Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks)
|
||||
if (validations.geolocation && validations.geolocation.status !== "error") {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
validations.bogon = bogonResult;
|
||||
|
||||
// Calculate overall health score (0-100)
|
||||
var checks = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
var totalWeight = 0;
|
||||
var earnedScore = 0;
|
||||
var checkResults = [];
|
||||
|
||||
checks.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;
|
||||
var duration = Date.now() - start;
|
||||
|
||||
// Build relationships from neighbour data
|
||||
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 }; });
|
||||
|
||||
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: {
|
||||
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.",
|
||||
},
|
||||
};
|
||||
// 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main lookup endpoint: /api/lookup?asn=X
|
||||
|
||||
62
server/routes/validate/bogon.js
Normal file
62
server/routes/validate/bogon.js
Normal file
@ -0,0 +1,62 @@
|
||||
// 11. Bogon Detection (local check, no external API calls)
|
||||
function checkBogonPrefix(prefix) {
|
||||
var bogonV4 = [
|
||||
{ net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 },
|
||||
{ net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 },
|
||||
{ net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 },
|
||||
{ net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 },
|
||||
{ net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 },
|
||||
{ net: "240.0.0.0", mask: 4 },
|
||||
];
|
||||
if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" };
|
||||
var split = prefix.split("/");
|
||||
var addr = split[0];
|
||||
var mask = parseInt(split[1] || "0");
|
||||
var parts = addr.split(".").map(Number);
|
||||
var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
for (var bi = 0; bi < bogonV4.length; bi++) {
|
||||
var b = bogonV4[bi];
|
||||
var bParts = b.net.split(".").map(Number);
|
||||
var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0;
|
||||
var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0;
|
||||
if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) {
|
||||
return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask };
|
||||
}
|
||||
}
|
||||
return { prefix: prefix, is_bogon: false };
|
||||
}
|
||||
|
||||
function checkBogonAsn(asnNum) {
|
||||
if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true;
|
||||
if (asnNum >= 64496 && asnNum <= 64511) return true;
|
||||
if (asnNum >= 64512 && asnNum <= 65534) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// "pass" must mean prefixes/neighbours were actually fetched and checked clean --
|
||||
// not that the upstream source failed and left nothing to check. A source is
|
||||
// unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a
|
||||
// truthy object with status:"error" (local-db-client.js's DB-error signal --
|
||||
// see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check
|
||||
// alone misses this case and would show "pass" on 0 checked prefixes).
|
||||
function isSourceUnavailable(x) { return !x || x.status === "error"; }
|
||||
|
||||
function computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData) {
|
||||
var bogonPrefixResults = allPrefixes.map(checkBogonPrefix);
|
||||
var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
|
||||
var asnInPaths = neighbours.map(function(n) { return n.asn; });
|
||||
var bogonAsns = asnInPaths.filter(checkBogonAsn);
|
||||
var prefixesUnavailable = isSourceUnavailable(prefixData);
|
||||
var neighboursUnavailable = isSourceUnavailable(neighbourData);
|
||||
var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable;
|
||||
return {
|
||||
status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"),
|
||||
bogon_prefixes: bogonPrefixes,
|
||||
bogon_asns_in_paths: bogonAsns,
|
||||
total_prefixes_checked: allPrefixes.length,
|
||||
prefixes_source_unavailable: prefixesUnavailable,
|
||||
neighbours_source_unavailable: neighboursUnavailable,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeBogonResult };
|
||||
337
server/routes/validate/checks.js
Normal file
337
server/routes/validate/checks.js
Normal file
@ -0,0 +1,337 @@
|
||||
const { fetchJSON, fetchBgproutesVisibility } = require("../../services/http-helpers");
|
||||
const { fetchRipeStatCached } = require("../../services/ripe-stat");
|
||||
const { fetchPeeringDB } = require("../../services/peeringdb");
|
||||
const { validateRPKIWithCache } = require("../../services/rpki");
|
||||
const { ensureManrsCache, checkManrsMembership } = require("../../services/manrs");
|
||||
|
||||
// 12. IRR Validation
|
||||
// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than
|
||||
// rejecting, so the .catch() below never sees the common failure mode -- a
|
||||
// dead upstream would otherwise silently read as "0 entries, no mismatches, pass".
|
||||
function checkIRR(rawAsn) {
|
||||
return fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
|
||||
if (irrData === null) {
|
||||
return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 };
|
||||
}
|
||||
var entries = Array.isArray(irrData) ? irrData : [];
|
||||
var mismatches = entries.filter(function(e) {
|
||||
if (!e.bgpOrigins && !e.bgp_origins) return false;
|
||||
if (!e.irrRoutes && !e.irr_origins) return false;
|
||||
var bgpArr = e.bgpOrigins || e.bgp_origins || [];
|
||||
var irrArr = e.irrRoutes || e.irr_origins || [];
|
||||
var bgpSet = {};
|
||||
bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; });
|
||||
var match = false;
|
||||
irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; });
|
||||
return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match;
|
||||
});
|
||||
return {
|
||||
status: mismatches.length === 0 ? "pass" : "warning",
|
||||
total_entries: entries.length,
|
||||
mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }),
|
||||
mismatch_count: mismatches.length,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs)
|
||||
// Caller must await ensureAspaCache() before calling this.
|
||||
function checkRpkiCompleteness(rawAsn, allPrefixes) {
|
||||
return Promise.all(
|
||||
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
|
||||
).then(function(rpkiResults) {
|
||||
var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
|
||||
var unavailableCount = rpkiResults.length - checkedResults.length;
|
||||
var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; });
|
||||
var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0;
|
||||
var overSpecific = checkedResults.filter(function(r) {
|
||||
var mask = parseInt((r.prefix || "").split("/")[1] || "0");
|
||||
return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
|
||||
});
|
||||
// If the local RPKI DB was unreachable for every prefix, we haven't actually
|
||||
// checked anything -- say so instead of reporting a misleading pass/fail.
|
||||
var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
|
||||
coverage_pct: coverage,
|
||||
total_checked: checkedResults.length,
|
||||
db_unavailable_count: unavailableCount,
|
||||
with_roa: withRoa.length,
|
||||
over_specific: overSpecific.map(function(r) { return r.prefix; }),
|
||||
details: rpkiResults,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 14. Abuse Contact Validation
|
||||
function checkAbuseContact(rawAsn) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
|
||||
if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false };
|
||||
var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
|
||||
var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
|
||||
return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 15. Spamhaus DROP/Blocklist
|
||||
function checkBlocklist(samplePrefixes) {
|
||||
return Promise.all(
|
||||
samplePrefixes.slice(0, 5).map(function(pfx) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
|
||||
// fetchRipeStatCached resolves(null) on timeout/network-error rather than
|
||||
// throwing, so the .catch() below never sees that case -- distinguish it
|
||||
// explicitly instead of letting a failed lookup read as "not listed".
|
||||
if (data === null) return { prefix: pfx, listed: false, checked: false };
|
||||
var sources = data && data.data && data.data.sources ? data.data.sources : [];
|
||||
var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
|
||||
return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
|
||||
}).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
|
||||
})
|
||||
).then(function(results) {
|
||||
var checkedResults = results.filter(function(r) { return r.checked; });
|
||||
var listedPrefixes = checkedResults.filter(function(r) { return r.listed; });
|
||||
var allUnchecked = results.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"),
|
||||
checked: checkedResults.length,
|
||||
unavailable: results.length - checkedResults.length,
|
||||
listed_prefixes: listedPrefixes,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 16. MANRS Compliance — scraped from public participants list (24h cache)
|
||||
function checkManrs(rawAsn) {
|
||||
return ensureManrsCache().then(function() {
|
||||
return checkManrsMembership(rawAsn);
|
||||
}).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; });
|
||||
}
|
||||
|
||||
// 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs)
|
||||
function checkRdns(samplePrefixes) {
|
||||
var rdnsSampleSize = Math.min(3, samplePrefixes.length);
|
||||
return Promise.all(
|
||||
samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
|
||||
if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true };
|
||||
var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
|
||||
var hasDelegation = false;
|
||||
var details = [];
|
||||
// API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } }
|
||||
["ipv4", "ipv6"].forEach(function(af) {
|
||||
var afData = pfxData[af] || {};
|
||||
Object.keys(afData).forEach(function(p) {
|
||||
var entry = afData[p];
|
||||
if (entry && entry.complete) hasDelegation = true;
|
||||
if (entry && entry.domains) {
|
||||
entry.domains.forEach(function(d) {
|
||||
if (d.found) hasDelegation = true;
|
||||
details.push({ domain: d.domain, found: !!d.found });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
// Fallback: old array format
|
||||
if (Array.isArray(pfxData)) {
|
||||
pfxData.forEach(function(p) {
|
||||
if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true;
|
||||
});
|
||||
}
|
||||
return { prefix: pfx, has_rdns: hasDelegation, details: details };
|
||||
}).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
|
||||
})
|
||||
).then(function(results) {
|
||||
var checkedResults = results.filter(function(r) { return !r.error; });
|
||||
var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
|
||||
var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
|
||||
var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; });
|
||||
var allUnavailable = results.length > 0 && checkedResults.length === 0;
|
||||
return {
|
||||
status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"),
|
||||
coverage_pct: coverage,
|
||||
checked: checkedResults.length,
|
||||
unavailable: results.length - checkedResults.length,
|
||||
results: results,
|
||||
failed_prefixes: failedPrefixes,
|
||||
};
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
|
||||
function checkVisibility(rawAsn, samplePrefixes) {
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) {
|
||||
var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
|
||||
var v4 = vis.v4 || {};
|
||||
var v6 = vis.v6 || {};
|
||||
var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
|
||||
var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
|
||||
var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
|
||||
var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
|
||||
// If routing-status returned no data, try bgproutes.io
|
||||
if (totalPeers === 0 && samplePrefixes[0]) {
|
||||
return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
|
||||
if (bgprFb && bgprFb.vps_seeing > 0) {
|
||||
seeingPeers = bgprFb.vps_seeing;
|
||||
totalPeers = Math.max(bgprFb.vps_seeing, 300);
|
||||
score = Math.round((seeingPeers / totalPeers) * 100);
|
||||
}
|
||||
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
|
||||
}).catch(function() {
|
||||
// Neither RIPE routing-status nor the bgproutes.io fallback returned data --
|
||||
// this network hasn't actually been measured, not confirmed at 0% visibility.
|
||||
return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
|
||||
});
|
||||
}
|
||||
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 19. BGP Communities Analysis
|
||||
function checkCommunities(samplePrefixes) {
|
||||
return (samplePrefixes.length > 0
|
||||
? (function() {
|
||||
var now = new Date();
|
||||
var end = now.toISOString().replace(/\.\d+Z/, "");
|
||||
var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, "");
|
||||
return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end);
|
||||
})()
|
||||
: Promise.resolve(null)
|
||||
).then(function(data) {
|
||||
var updates = data && data.data && data.data.updates ? data.data.updates : [];
|
||||
var communityMap = {};
|
||||
var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" };
|
||||
updates.forEach(function(u) {
|
||||
var attrs = u.attrs || {};
|
||||
var communities = attrs.community || [];
|
||||
communities.forEach(function(c) {
|
||||
var key = Array.isArray(c) ? c.join(":") : String(c);
|
||||
if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null };
|
||||
communityMap[key].count++;
|
||||
});
|
||||
});
|
||||
var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; });
|
||||
var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; });
|
||||
return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 20. Geolocation Verification
|
||||
function checkGeolocation(samplePrefixes) {
|
||||
return (samplePrefixes.length > 0
|
||||
? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0]))
|
||||
: Promise.resolve(null)
|
||||
).then(function(data) {
|
||||
var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : [];
|
||||
var countries = {};
|
||||
locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); });
|
||||
return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel)
|
||||
function checkRpsl(rawAsn) {
|
||||
// Try RIPE first (has richest policy data), then RDAP for other RIRs
|
||||
var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) {
|
||||
var objects = data && data.objects && data.objects.object ? data.objects.object : [];
|
||||
if (objects.length === 0) return null;
|
||||
var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : [];
|
||||
var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; });
|
||||
var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; });
|
||||
var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; });
|
||||
return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" };
|
||||
}).catch(function() { return null; });
|
||||
|
||||
var rdapEndpoints = [
|
||||
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn },
|
||||
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn },
|
||||
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn },
|
||||
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn },
|
||||
];
|
||||
var rdapPromises = rdapEndpoints.map(function(ep) {
|
||||
return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) {
|
||||
if (!data || data.errorCode || !data.handle) return null;
|
||||
var hasRemarks = !!(data.remarks && data.remarks.length > 0);
|
||||
var name = data.name || "";
|
||||
return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" };
|
||||
}).catch(function() { return null; });
|
||||
});
|
||||
|
||||
return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) {
|
||||
// Take first successful result
|
||||
for (var ri = 0; ri < results.length; ri++) {
|
||||
if (results[ri] !== null) return results[ri];
|
||||
}
|
||||
return { status: "warning", exists: false, has_policy: false };
|
||||
});
|
||||
}
|
||||
|
||||
// 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering)
|
||||
// Always use asn= for netixlan (more reliable than net_id when PDB rate-limits)
|
||||
function checkIxRouteServer(rawAsn) {
|
||||
var ixRsQueryUrl = "/netixlan?asn=" + rawAsn;
|
||||
return fetchPeeringDB(ixRsQueryUrl).then(function(ixData) {
|
||||
var connections = ixData && ixData.data ? ixData.data : [];
|
||||
var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; });
|
||||
var totalIx = connections.length;
|
||||
var rsCount = rsParticipants.length;
|
||||
var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0;
|
||||
var status, note;
|
||||
|
||||
if (totalIx > 0 && rsCount > 0) {
|
||||
// Using route servers - good
|
||||
status = "pass";
|
||||
note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)";
|
||||
} else if (totalIx >= 10 && rsCount === 0) {
|
||||
// Network with 10+ IX connections but no RS = deliberate bilateral peering policy
|
||||
status = "pass";
|
||||
note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)";
|
||||
} else if (totalIx < 3 && rsCount === 0) {
|
||||
// Very small IX presence and no RS
|
||||
status = "warning";
|
||||
note = "Only " + totalIx + " IX connection(s) and no route server usage";
|
||||
} else {
|
||||
// Small-medium network (3-9 IX) without RS - informational
|
||||
status = "info";
|
||||
note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability";
|
||||
}
|
||||
|
||||
return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// 23. Resource Certification (local RPKI validation - all prefixes, all RIRs)
|
||||
function checkResourceCert(rawAsn, allPrefixes) {
|
||||
return Promise.all(
|
||||
allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
|
||||
).then(function(results) {
|
||||
var checked = results.filter(function(r) { return r.status !== "unavailable"; });
|
||||
var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
|
||||
var allUnavailable = results.length > 0 && checked.length === 0;
|
||||
return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length };
|
||||
}).catch(function(e) { return { status: "error", error: String(e) }; });
|
||||
}
|
||||
|
||||
// Geolocation cross-ref with PeeringDB facilities
|
||||
function fetchFacCountries(netId) {
|
||||
return netId
|
||||
? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) {
|
||||
return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean);
|
||||
}).catch(function() { return []; })
|
||||
: Promise.resolve([]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkIRR,
|
||||
checkRpkiCompleteness,
|
||||
checkAbuseContact,
|
||||
checkBlocklist,
|
||||
checkManrs,
|
||||
checkRdns,
|
||||
checkVisibility,
|
||||
checkCommunities,
|
||||
checkGeolocation,
|
||||
checkRpsl,
|
||||
checkIxRouteServer,
|
||||
checkResourceCert,
|
||||
fetchFacCountries,
|
||||
};
|
||||
208
server/routes/validate/index.js
Normal file
208
server/routes/validate/index.js
Normal file
@ -0,0 +1,208 @@
|
||||
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 };
|
||||
Loading…
x
Reference in New Issue
Block a user