fix: 6 validation improvements from user feedback (AS212635)

1. MANRS: API requires auth → now shows "info" (unable to verify)
   instead of false "not a participant". Excludes from scoring.
2. BGP Visibility: switched from broken visibility API to
   routing-status API. AS212635: 0/0 → 327/327 v4, 319/320 v6
3. Reverse DNS: fixed response parsing (object vs array format).
   AS212635: 0% → 100% coverage
4. ASPA: upstream vs peer classification using power heuristic.
   >10% of max power = likely_upstream, rest = likely_peer.
   AS212635: 53 "providers" → 6 likely_upstream + 47 likely_peer
5. Geolocation: global networks properly detected
6. Score: "info" status excluded from scoring (neutral)

AS212635 score: ~70 → 98/100
This commit is contained in:
Rene Fichtmueller 2026-03-28 01:49:00 +13:00
parent fd7b2cdb64
commit 0eaad0034f

107
server.js
View File

@ -1154,9 +1154,14 @@ const server = http.createServer(async (req, res) => {
const upstreamSet = new Set(); const upstreamSet = new Set();
leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); 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 detectedProviders = [...upstreamSet].map((asn) => {
const nb = leftNeighbours.find((n) => n.asn === asn); const nb = leftNeighbours.find((n) => n.asn === asn);
return { asn, name: nb && nb.as_name ? nb.as_name : "" }; 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); await resolveASNames(detectedProviders);
@ -1379,9 +1384,14 @@ const server = http.createServer(async (req, res) => {
const upstreamSet = new Set(); const upstreamSet = new Set();
leftNeighbours.forEach((n) => upstreamSet.add(n.asn)); 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 detectedProviders = [...upstreamSet].map((asn) => {
const nb = leftNeighbours.find((n) => n.asn === asn); const nb = leftNeighbours.find((n) => n.asn === asn);
return { asn, name: nb && nb.as_name ? nb.as_name : "" }; 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); await resolveASNames(detectedProviders);
@ -1714,56 +1724,82 @@ const server = http.createServer(async (req, res) => {
return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes }; return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes };
}).catch(function(e) { return { status: "error", error: String(e) }; }); }).catch(function(e) { return { status: "error", error: String(e) }; });
// 16. MANRS Compliance // 16. MANRS Compliance (observatory API requires auth — use fallback indicators)
validationPromises.manrs = fetchJSON("https://observatory.manrs.org/api/v2/asn/" + rawAsn + "/conformance").then(function(data) { validationPromises.manrs = fetchJSON("https://observatory.manrs.org/api/v2/asn/" + rawAsn + "/conformance", { timeout: 5000 }).then(function(data) {
if (!data || data.error || data.detail) return { status: "warning", participant: false, message: (data && data.detail) || "Not a MANRS participant" }; if (!data || data.error || data.detail === "Authentication credentials were not provided.") {
// API unavailable — check MANRS indicators: RPKI ROA + IRR objects as proxy
var hasRoa = samplePrefixes.length > 0; // will be checked by RPKI validation
var hasIrr = !!(net.irr_as_set);
if (hasRoa && hasIrr) {
return { status: "info", participant: "unknown", message: "MANRS Observatory API requires authentication — cannot verify membership. Network has ROA + IRR objects (positive indicators).", note: "Unable to verify — MANRS API requires auth. Check https://observatory.manrs.org/asn/" + rawAsn };
}
return { status: "info", participant: "unknown", message: "Unable to verify MANRS membership (API requires authentication)", note: "Check manually: https://observatory.manrs.org/asn/" + rawAsn };
}
var score = data.conformance_score || data.score || 0; var score = data.conformance_score || data.score || 0;
return { status: score >= 50 ? "pass" : "warning", participant: true, score: score, details: data }; return { status: score >= 50 ? "pass" : "warning", participant: true, score: score, details: data };
}).catch(function(e) { return { status: "warning", participant: false, error: String(e) }; }); }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable", note: "https://observatory.manrs.org/asn/" + rawAsn }; });
// 17. Reverse DNS Coverage // 17. Reverse DNS Coverage
validationPromises.rdns = Promise.all( validationPromises.rdns = Promise.all(
samplePrefixes.slice(0, 5).map(function(pfx) { samplePrefixes.slice(0, 5).map(function(pfx) {
return fetchJSON("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { return fetchJSON("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 15000 }).then(function(data) {
var prefixes = data && data.data && data.data.prefixes ? data.data.prefixes : []; var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
var hasDelegation = prefixes.some(function(p) { return p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0); }); var hasDelegation = false;
return { prefix: pfx, has_rdns: hasDelegation }; 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 }; }); }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
}) })
).then(function(results) { ).then(function(results) {
var withRdns = results.filter(function(r) { return r.has_rdns; }); var withRdns = results.filter(function(r) { return r.has_rdns; });
var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0; var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0;
return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.length, results: results }; // Include details of what failed
var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; });
return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.length, results: results, failed_prefixes: failedPrefixes };
}).catch(function(e) { return { status: "error", error: String(e) }; }); }).catch(function(e) { return { status: "error", error: String(e) }; });
// 18. Historical BGP Visibility // 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
validationPromises.visibility = (samplePrefixes.length > 0 validationPromises.visibility = fetchJSON("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 20000 }).then(function(rsData) {
? Promise.all([ var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
fetchJSON("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(samplePrefixes[0])), var v4 = vis.v4 || {};
fetchJSON("https://stat.ripe.net/data/routing-history/data.json?resource=" + encodeURIComponent(samplePrefixes[0])), var v6 = vis.v6 || {};
]) var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
: Promise.resolve([null, null]) var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
).then(function(arr) { var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
var visData = arr[0]; var histData = arr[1]; var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
var visibilities = visData && visData.data && visData.data.visibilities ? visData.data.visibilities : []; // If routing-status returned no data, try bgproutes.io
var totalRrcs = visibilities.length; if (totalPeers === 0 && samplePrefixes[0]) {
var seenBy = visibilities.filter(function(v) { return (v.rrcs_seeing || v.ipv4_full_table_peer_count || 0) > 0; }).length;
var score = totalRrcs > 0 ? Math.round((seenBy / totalRrcs) * 100) : 0;
var history = histData && histData.data && histData.data.by_origin ? histData.data.by_origin : [];
// If RIPE Stat returned no data, try bgproutes.io fallback
if (totalRrcs === 0 && samplePrefixes[0]) {
return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) { return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
if (bgprFb && bgprFb.vps_seeing > 0) { if (bgprFb && bgprFb.vps_seeing > 0) {
seenBy = bgprFb.vps_seeing; seeingPeers = bgprFb.vps_seeing;
totalRrcs = Math.max(bgprFb.vps_seeing, 300); totalPeers = Math.max(bgprFb.vps_seeing, 300);
score = Math.round((seenBy / totalRrcs) * 100); score = Math.round((seeingPeers / totalPeers) * 100);
} }
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_rrcs: totalRrcs, seen_by: seenBy, origin_changes: history.length, sample_prefix: samplePrefixes[0] || null }; 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() { }).catch(function() {
return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_rrcs: totalRrcs, seen_by: seenBy, origin_changes: history.length, sample_prefix: samplePrefixes[0] || null }; return { status: "fail", 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_rrcs: totalRrcs, seen_by: seenBy, origin_changes: history.length, sample_prefix: samplePrefixes[0] || null }; 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) }; }); }).catch(function(e) { return { status: "error", error: String(e) }; });
// 19. BGP Communities Analysis // 19. BGP Communities Analysis
@ -1959,6 +1995,11 @@ const server = http.createServer(async (req, res) => {
checks.forEach(function(c) { checks.forEach(function(c) {
var v = validations[c.key]; var v = validations[c.key];
var points = 0; 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; if (v && v.status === "pass") points = c.weight;
else if (v && v.status === "warning") points = Math.round(c.weight * 0.5); else if (v && v.status === "warning") points = Math.round(c.weight * 0.5);
totalWeight += c.weight; totalWeight += c.weight;