Verified via node --check, eslint no-undef (2 known bugs remain: compare, webhooks), and smoke-test harness (28/28 match).
272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
|
const { hasAsSet, verifyUpstream, verifyDownstream, detectValleys, collapsePrepends, calculateAspaReadinessScore } = require("../aspa-verification/engine");
|
|
const { resolveASNames } = require("../resolve-as-names");
|
|
const { ensureAspaCache, lookupAspaFromRpki, rpkiAspaMap, validateRPKIWithCache } = require("../services/rpki");
|
|
const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
|
|
|
// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
|
|
async function handleAspaVerify(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 cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn);
|
|
if (cachedVerify !== undefined) {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify(cachedVerify));
|
|
}
|
|
const targetAsn = parseInt(rawAsn);
|
|
const start = Date.now();
|
|
|
|
try {
|
|
// Fetch neighbour and prefix data first
|
|
const [neighbourData, prefixData] = await Promise.all([
|
|
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
|
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
|
]);
|
|
|
|
// Use looking-glass with actual prefixes to get BGP paths
|
|
const announcedPrefixes = prefixData?.data?.prefixes || [];
|
|
const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3
|
|
|
|
// Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each)
|
|
const lgResults = await Promise.all(
|
|
samplePrefixes.map((pfx) =>
|
|
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null)
|
|
)
|
|
);
|
|
|
|
// Extract AS paths from looking glass results
|
|
const allPaths = [];
|
|
const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths
|
|
|
|
lgResults.forEach((lgData) => {
|
|
const rrcs = lgData?.data?.rrcs || [];
|
|
rrcs.forEach((rrc) => {
|
|
const peers = rrc.peers || [];
|
|
peers.forEach((peer) => {
|
|
const rawPath = peer.as_path || "";
|
|
const pathArr = rawPath.split(" ").map(Number).filter(Boolean);
|
|
if (pathArr.length > 1) {
|
|
allPaths.push({
|
|
rrc: rrc.rrc,
|
|
path: pathArr,
|
|
rawPath: rawPath,
|
|
prefix: peer.prefix || "",
|
|
hasAsSet: hasAsSet(rawPath),
|
|
});
|
|
const idx = pathArr.indexOf(targetAsn);
|
|
if (idx > 0) {
|
|
const neighbour = pathArr[idx - 1];
|
|
pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
|
|
// AS-path analysis is used for frequency/confirmation, NOT as standalone provider source
|
|
const neighbours = neighbourData?.data?.neighbours || [];
|
|
const leftNeighbours = neighbours.filter((n) => n.type === "left");
|
|
const upstreamSet = new Set();
|
|
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
|
|
|
|
// Classify left neighbours: high-power = likely upstream, low-power = likely peer
|
|
const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
|
|
const detectedProviders = [...upstreamSet].map((asn) => {
|
|
const nb = leftNeighbours.find((n) => n.asn === asn);
|
|
const power = nb ? (nb.power || 0) : 0;
|
|
const powerPct = Math.round((power / maxPower) * 100);
|
|
const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
|
|
return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
|
|
});
|
|
|
|
await resolveASNames(detectedProviders);
|
|
|
|
// Count how often each provider appears in paths
|
|
const providerFrequency = new Map();
|
|
allPaths.forEach((p) => {
|
|
const idx = p.path.indexOf(targetAsn);
|
|
if (idx > 0) {
|
|
const prov = p.path[idx - 1];
|
|
providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1);
|
|
}
|
|
});
|
|
|
|
// Check Cloudflare RPKI feed for ASPA object
|
|
await ensureAspaCache();
|
|
const aspaLookup = lookupAspaFromRpki(targetAsn);
|
|
const aspaObjectExists = aspaLookup.exists;
|
|
const aspaDeclaredProviders = aspaLookup.providers;
|
|
|
|
// Build ASPA store from RPKI feed data (real ASPA objects only). If the
|
|
// target has no real ASPA object, its hops must show "NoAttestation" --
|
|
// NOT a fabricated declaration built from heuristically-detected BGP
|
|
// neighbours, which would contradict the aspa_object_exists:false this
|
|
// same response reports and falsely present unattested hops as
|
|
// "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for
|
|
// any ASN with no entry in aspaStore, so simply not setting one here is
|
|
// correct and sufficient -- no fallback needed.
|
|
const aspaStore = new Map();
|
|
if (aspaObjectExists) {
|
|
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
|
|
}
|
|
// Also populate store with all known ASPA objects from the RPKI feed
|
|
// for providers that have their own ASPA objects (enables full path verification)
|
|
for (const [cas, provSet] of rpkiAspaMap) {
|
|
if (!aspaStore.has(cas)) {
|
|
aspaStore.set(cas, provSet);
|
|
}
|
|
}
|
|
// Deliberately NOT adding empty Set() entries for detected-but-unattested
|
|
// providers here: aspaStore.get() returning undefined for them makes
|
|
// hopCheck() correctly report "NoAttestation". An empty (but present) Set
|
|
// would make providers.has(x) return false, misreporting "NotProviderPlus"
|
|
// (implying a real ASPA object that denies this hop) instead of "we simply
|
|
// don't know" -- see the 2026-07-16 audit for how this previously caused
|
|
// clean, unattested paths to be misclassified as route leaks.
|
|
|
|
// Sample paths for verification (up to 50)
|
|
const samplePaths = allPaths.slice(0, 50);
|
|
const pathResults = samplePaths.map((p) => {
|
|
const upstream = verifyUpstream(p.path, aspaStore, p.rawPath);
|
|
const downstream = verifyDownstream(p.path, aspaStore, p.rawPath);
|
|
const valleys = detectValleys(p.path, aspaStore);
|
|
|
|
return {
|
|
rrc: p.rrc,
|
|
prefix: p.prefix,
|
|
path: p.path.map((a) => "AS" + a).join(" "),
|
|
collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "),
|
|
has_as_set: p.hasAsSet,
|
|
upstream_verification: upstream,
|
|
downstream_verification: downstream,
|
|
valleys: valleys,
|
|
overall: p.hasAsSet
|
|
? "Invalid"
|
|
: upstream.result === "Valid" && downstream.result === "Valid"
|
|
? "Valid"
|
|
: upstream.result === "Invalid" || downstream.result === "Invalid"
|
|
? "Invalid"
|
|
: "Unknown",
|
|
};
|
|
});
|
|
|
|
// Calculate statistics
|
|
const validPaths = pathResults.filter((p) => p.overall === "Valid").length;
|
|
const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length;
|
|
const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length;
|
|
const asSetPaths = pathResults.filter((p) => p.has_as_set).length;
|
|
const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length;
|
|
|
|
// For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal),
|
|
// only Invalid actually indicates problems
|
|
const pathNotInvalidPct = pathResults.length > 0
|
|
? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100)
|
|
: 0;
|
|
const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0;
|
|
|
|
// Provider audit: compare detected vs declared
|
|
const detectedSet = new Set(detectedProviders.map((p) => p.asn));
|
|
const declaredSet = new Set(aspaDeclaredProviders);
|
|
|
|
const missingFromAspa = detectedProviders
|
|
.filter((p) => !declaredSet.has(p.asn))
|
|
.map((p) => ({
|
|
asn: p.asn,
|
|
name: p.name,
|
|
frequency: providerFrequency.get(p.asn) || 0,
|
|
frequency_pct: allPaths.length > 0
|
|
? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
|
|
: 0,
|
|
}))
|
|
.sort((a, b) => b.frequency - a.frequency);
|
|
|
|
const extraInAspa = aspaDeclaredProviders
|
|
.filter((asn) => !detectedSet.has(asn))
|
|
.map((asn) => ({
|
|
asn,
|
|
name: "",
|
|
seen_in_paths: false,
|
|
}));
|
|
|
|
const providerCompleteness = detectedProviders.length > 0
|
|
? Math.round(
|
|
(detectedProviders.filter((p) => declaredSet.has(p.asn)).length /
|
|
detectedProviders.length) *
|
|
100
|
|
)
|
|
: aspaObjectExists ? 100 : 0;
|
|
|
|
// Get RPKI coverage for readiness score
|
|
// Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs)
|
|
await ensureAspaCache();
|
|
const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
|
|
const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
|
|
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
|
// must not lower a network's displayed ASPA readiness score.
|
|
const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable");
|
|
const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length;
|
|
const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0;
|
|
|
|
// Calculate readiness score
|
|
const readinessScore = calculateAspaReadinessScore({
|
|
rpkiCoverage,
|
|
aspaObjectExists,
|
|
providerCompleteness,
|
|
pathValidationPct: pathNotInvalidPct,
|
|
});
|
|
|
|
const duration = Date.now() - start;
|
|
const verifyResult = {
|
|
meta: {
|
|
query: "AS" + rawAsn,
|
|
duration_ms: duration,
|
|
timestamp: new Date().toISOString(),
|
|
paths_analyzed: pathResults.length,
|
|
total_paths_seen: allPaths.length,
|
|
},
|
|
asn: targetAsn,
|
|
readiness_score: readinessScore,
|
|
aspa_object_exists: aspaObjectExists,
|
|
aspa_feed_healthy: aspaLookup.feedLoaded,
|
|
detected_providers: detectedProviders.map((p) => ({
|
|
...p,
|
|
frequency: providerFrequency.get(p.asn) || 0,
|
|
frequency_pct: allPaths.length > 0
|
|
? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
|
|
: 0,
|
|
})),
|
|
provider_audit: {
|
|
declared_count: aspaDeclaredProviders.length,
|
|
detected_count: detectedProviders.length,
|
|
completeness_pct: providerCompleteness,
|
|
missing_from_aspa: missingFromAspa,
|
|
extra_in_aspa: extraInAspa,
|
|
},
|
|
path_verification: {
|
|
total: pathResults.length,
|
|
valid: validPaths,
|
|
invalid: invalidPaths,
|
|
unknown: unknownPaths,
|
|
as_set_flagged: asSetPaths,
|
|
valley_detected: valleyPaths,
|
|
valid_pct: pathValidPct,
|
|
not_invalid_pct: pathNotInvalidPct,
|
|
results: pathResults,
|
|
},
|
|
rpki_coverage: rpkiCoverage,
|
|
};
|
|
resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult);
|
|
return res.end(JSON.stringify(verifyResult, null, 2));
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message }));
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = { handleAspaVerify };
|