feat: multi-source data validation with confidence scoring

- RPKI cross-check: Cloudflare RPKI feed + RIPE NCC Validator API (5 sample prefixes)
- Prefix cross-check: RIPE Stat vs bgp.he.net count comparison
- Neighbour cross-check: RIPE Stat vs bgp.he.net peer data
- Data Quality badge in dashboard (High/Medium/Low confidence)
- Hover tooltip: "Data Quality Report" with per-source agreement breakdown
- Added BETA tag to site header and version string (v0.5.0-beta)
- All UI text in English
This commit is contained in:
Rene Fichtmueller 2026-03-27 10:22:10 +13:00
parent 6fdda92757
commit 13c5152bf9
2 changed files with 295 additions and 6 deletions

View File

@ -100,7 +100,19 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.skeleton.h2{height:2rem;width:60%}.skeleton.h3{height:1.2rem;width:40%}.skeleton.wide{width:100%}.skeleton.med{width:70%}
.meta-bar{text-align:center;font-size:.75rem;color:var(--dim);margin-top:1rem;padding:0 1.5rem}
.meta-bar{text-align:center;font-size:.75rem;color:var(--dim);margin-top:1rem;padding:0 1.5rem;display:flex;align-items:center;justify-content:center;gap:.75rem;flex-wrap:wrap}
.dq-badge{display:inline-flex;align-items:center;gap:.35rem;padding:.25rem .65rem;border-radius:6px;font-size:.7rem;font-weight:600;cursor:help;position:relative;transition:all .2s}
.dq-badge.high{background:rgba(158,206,106,.15);color:var(--green);border:1px solid rgba(158,206,106,.3)}
.dq-badge.medium{background:rgba(255,158,100,.15);color:var(--orange);border:1px solid rgba(255,158,100,.3)}
.dq-badge.low{background:rgba(247,118,142,.15);color:var(--red);border:1px solid rgba(247,118,142,.3)}
.dq-badge svg{width:12px;height:12px}
.dq-tooltip{display:none;position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--border-light);border-radius:8px;padding:.75rem;font-size:.7rem;font-weight:400;color:var(--text-dim);min-width:280px;max-width:360px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.3);text-align:left;line-height:1.5}
.dq-badge:hover .dq-tooltip{display:block}
.dq-tooltip-row{display:flex;justify-content:space-between;padding:.2rem 0;border-bottom:1px solid rgba(42,43,61,.5)}
.dq-tooltip-row:last-child{border-bottom:none}
.dq-tooltip-label{color:var(--muted)}
.dq-tooltip-value{font-weight:600}
.dq-tooltip-value.agree{color:var(--green)}.dq-tooltip-value.warn{color:var(--orange)}.dq-tooltip-value.bad{color:var(--red)}.dq-tooltip-value.na{color:var(--dim)}
.footer{text-align:center;padding:2rem 1.5rem;color:var(--dim);font-size:.75rem;border-top:1px solid var(--border);margin-top:2rem}
.footer a{color:var(--muted)}
@ -267,7 +279,7 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
<line x1="18" y1="13" x2="25" y2="22" stroke="#565f89" stroke-width="1.5"/>
<line x1="13" y1="24" x2="23" y2="24" stroke="#565f89" stroke-width="1.5"/>
</svg>
<div><h1>PeerCortex</h1><span>Network Intelligence Dashboard v0.3</span></div>
<div><h1>PeerCortex <span style="font-size:.45em;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:2px 10px;border-radius:4px;vertical-align:middle;letter-spacing:.08em;font-weight:700">BETA</span></h1><span>Network Intelligence Dashboard v0.5.0-beta</span></div>
</div>
<nav class="quick-links">
<a href="https://github.com/peercortex/peercortex" target="_blank">GitHub</a>
@ -667,6 +679,129 @@ function renderAuditList(containerId, list, sortBy, type) {
document.getElementById(containerId).innerHTML = h;
}
function renderMetaBar(d) {
var bar = $('metaBar');
// Clear previous content
while (bar.firstChild) bar.removeChild(bar.firstChild);
// Meta text span
var metaSpan = document.createElement('span');
metaSpan.textContent = 'Query: ' + d.meta.query + ' | Duration: ' + d.meta.duration_ms + 'ms | RPKI checked: ' + d.meta.rpki_prefixes_checked + '/' + d.meta.total_prefixes + ' prefixes | ' + d.meta.timestamp;
bar.appendChild(metaSpan);
// Data Quality badge
var dq = d.data_quality;
if (dq) {
var conf = dq.overall_confidence || 'high';
var badge = document.createElement('span');
badge.className = 'dq-badge ' + conf;
// Shield icon
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z');
svg.appendChild(path);
badge.appendChild(svg);
var label = document.createElement('span');
var confLabel = conf === 'high' ? 'High Confidence' : conf === 'medium' ? 'Medium Confidence' : 'Low Confidence';
label.textContent = confLabel;
badge.appendChild(label);
// Tooltip
var tooltip = document.createElement('div');
tooltip.className = 'dq-tooltip';
var title = document.createElement('div');
title.style.cssText = 'font-weight:600;color:var(--white);margin-bottom:.5rem;font-size:.75rem';
title.textContent = 'Data Quality Report';
tooltip.appendChild(title);
var sources = document.createElement('div');
sources.style.cssText = 'margin-bottom:.5rem;color:var(--dim);font-size:.65rem';
sources.textContent = 'Sources Queried: ' + (dq.sources_queried || []).join(', ');
tooltip.appendChild(sources);
// Cross-Checks header
var checksHeader = document.createElement('div');
checksHeader.style.cssText = 'font-weight:600;color:var(--white);margin-bottom:.3rem;margin-top:.3rem;font-size:.7rem';
checksHeader.textContent = 'Cross-Checks';
tooltip.appendChild(checksHeader);
var checks = dq.cross_checks || {};
var checkNames = [['rpki', 'RPKI Validation'], ['prefixes', 'Prefix Count'], ['neighbours', 'Neighbours']];
var totalAgreement = 0;
var totalChecks = 0;
for (var ci = 0; ci < checkNames.length; ci++) {
var key = checkNames[ci][0];
var name = checkNames[ci][1];
var check = checks[key] || {};
var row = document.createElement('div');
row.className = 'dq-tooltip-row';
var rowLabel = document.createElement('span');
rowLabel.className = 'dq-tooltip-label';
rowLabel.textContent = name + ' (' + (check.sources || 0) + ' sources)';
row.appendChild(rowLabel);
var rowVal = document.createElement('span');
var pct = check.agreement_pct;
if (pct == null) {
rowVal.className = 'dq-tooltip-value na';
rowVal.textContent = 'N/A';
} else {
rowVal.className = 'dq-tooltip-value ' + (pct > 90 ? 'agree' : pct >= 70 ? 'warn' : 'bad');
rowVal.textContent = 'Agreement: ' + pct + '%';
totalAgreement += pct;
totalChecks++;
}
row.appendChild(rowVal);
tooltip.appendChild(row);
}
// Disagreements Found detail
var rpkiCheck = checks.rpki || {};
if (rpkiCheck.disagreements && rpkiCheck.disagreements.length > 0) {
var disDiv = document.createElement('div');
disDiv.style.cssText = 'margin-top:.4rem;font-size:.6rem;color:var(--orange)';
disDiv.textContent = 'Disagreements Found: ' + rpkiCheck.disagreements.map(function(dd) { return dd.prefix; }).join(', ');
tooltip.appendChild(disDiv);
}
// Prefix note
var pfxCheck = checks.prefixes || {};
if (pfxCheck.note) {
var noteDiv = document.createElement('div');
noteDiv.style.cssText = 'margin-top:.3rem;font-size:.6rem;color:var(--dim)';
noteDiv.textContent = pfxCheck.note;
tooltip.appendChild(noteDiv);
}
// Overall note
var avgAgreement = totalChecks > 0 ? totalAgreement / totalChecks : 100;
var overallNote = document.createElement('div');
overallNote.style.cssText = 'margin-top:.4rem;font-size:.6rem;font-style:italic;color:var(--muted)';
if (avgAgreement > 90) {
overallNote.textContent = 'All sources agree';
overallNote.style.color = 'var(--green)';
} else if (avgAgreement >= 70) {
overallNote.textContent = 'Minor discrepancies between sources';
overallNote.style.color = 'var(--orange)';
} else {
overallNote.textContent = 'Significant data disagreements detected';
overallNote.style.color = 'var(--red)';
}
tooltip.appendChild(overallNote);
badge.appendChild(tooltip);
bar.appendChild(badge);
}
}
async function doLookup() {
const raw = $('asnInput').value.trim().replace(/[^0-9]/g, '');
if (!raw) return;
@ -692,7 +827,7 @@ async function doLookup() {
renderDashboard(d);
$('skeleton').classList.add('hidden');
$('dashboard').classList.remove('hidden');
$('metaBar').textContent = 'Query: ' + d.meta.query + ' | Duration: ' + d.meta.duration_ms + 'ms | RPKI checked: ' + d.meta.rpki_prefixes_checked + '/' + d.meta.total_prefixes + ' prefixes | ' + d.meta.timestamp;
renderMetaBar(d);
history.replaceState(null, '', '?asn=' + raw);
saveToHistory(raw, d.network ? d.network.name : 'AS' + raw);

160
server.js
View File

@ -619,6 +619,78 @@ function calculateAspaReadinessScore(params) {
}
// ============================================================
// Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes)
// ============================================================
async function fetchRipeRpkiValidator(asn, prefix) {
try {
const encoded = encodeURIComponent(prefix);
const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded;
const result = await fetchJSON(url, { timeout: 5000 });
if (result && result.validated_route) {
return {
prefix: prefix,
validity: result.validated_route.validity || {},
state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown",
};
}
return { prefix: prefix, state: "unknown", error: "no_data" };
} catch (_e) {
return { prefix: prefix, state: "error", error: "timeout_or_unavailable" };
}
}
// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel)
async function crossCheckRpki(asn, prefixes, localResults) {
const sample = prefixes.slice(0, 5);
if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };
const ripeResults = await Promise.all(
sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
);
const localMap = new Map();
for (const lr of localResults) {
localMap.set(lr.prefix, lr.status);
}
let cloudflareValid = 0;
let ripeValid = 0;
let agreements = 0;
const disagreements = [];
for (let i = 0; i < sample.length; i++) {
const pfx = sample[i];
const cfStatus = localMap.get(pfx) || "not_found";
const ripeState = ripeResults[i].state;
const cfIsValid = cfStatus === "valid";
const ripeIsValid = ripeState === "valid" || ripeState === "VALID";
if (cfIsValid) cloudflareValid++;
if (ripeIsValid) ripeValid++;
// Skip comparison if RIPE returned error/unknown
if (ripeState === "error" || ripeState === "unknown") {
agreements++; // Don't count failed lookups as disagreements
continue;
}
if (cfIsValid === ripeIsValid) {
agreements++;
} else {
disagreements.push({
prefix: pfx,
cloudflare: cfStatus,
ripe: ripeState,
});
}
}
const agreementPct = sample.length > 0 ? Math.round((agreements / sample.length) * 100) : 100;
return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length };
}
// ============================================================
// Feature 24: bgp.he.net Integration
// ============================================================
@ -983,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
JSON.stringify({
status: "ok",
service: "PeerCortex",
version: "0.5.0",
version: "0.5.0-beta",
timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()),
bgproutes_configured: !!BGPROUTES_API_KEY,
@ -2077,13 +2149,88 @@ const server = http.createServer(async (req, res) => {
};
})();
// ============================================================
// Multi-source cross-checks (run in parallel, non-blocking)
// ============================================================
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 };
let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
try {
// RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
const rpkiCrossResult = await Promise.race([
rpkiCrossPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
]);
if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
} catch (_e) { /* cross-check failed, keep defaults */ }
// Prefix count cross-check: compare RIPE Stat vs bgp.he.net
if (bgpHeData) {
const heV4 = bgpHeData.prefixes_v4 || 0;
const heV6 = bgpHeData.prefixes_v6 || 0;
const heTotal = heV4 + heV6;
if (heTotal > 0) {
prefixCrossCheck.bgp_he_net = heTotal;
const ripeStat = prefixes.length;
if (ripeStat > 0 && heTotal > 0) {
const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
prefixCrossCheck.agreement = ratio >= 0.9;
const diff = Math.abs(ripeStat - heTotal);
prefixCrossCheck.note = diff === 0
? "Exact match"
: "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
}
} else {
prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
}
// Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
if (bgpHeData.peer_count != null) {
neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
}
} else {
prefixCrossCheck.note = "bgp.he.net data unavailable";
}
// Compute overall data quality
const crossCheckScores = [];
// RPKI agreement
crossCheckScores.push(rpkiCrossCheck.agreement_pct);
// Prefix agreement: convert to percentage
if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
crossCheckScores.push(Math.round(pfxRatio * 100));
}
// Neighbour agreement
if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
crossCheckScores.push(Math.round(nbrRatio * 100));
}
const avgAgreement = crossCheckScores.length > 0
? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
: 100;
const overallConfidence = avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
const dataQuality = {
sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
cross_checks: {
rpki: { sources: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements },
prefixes: { sources: 2, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
neighbours: { sources: 2, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
},
overall_confidence: overallConfidence,
overall_agreement_pct: avgAgreement,
};
const result = {
meta: {
service: "PeerCortex",
version: "0.5.0",
version: "0.5.0-beta",
query: "AS" + asn,
duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "Route Views"],
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
timestamp: new Date().toISOString(),
rpki_prefixes_checked: rpkiTotal,
total_prefixes: prefixes.length,
@ -2111,6 +2258,7 @@ const server = http.createServer(async (req, res) => {
ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
list: prefixes.map((p) => p.prefix),
cross_check: prefixCrossCheck,
},
rpki: {
coverage_percent: rpkiCoverage,
@ -2119,6 +2267,7 @@ const server = http.createServer(async (req, res) => {
not_found: rpkiNotFound,
checked: rpkiTotal,
details: rpkiStatuses,
cross_check: rpkiCrossCheck,
},
neighbours: {
total: neighbours.length,
@ -2128,6 +2277,7 @@ const server = http.createServer(async (req, res) => {
upstreams: upstreams.slice(0, 20),
downstreams: downstreams.slice(0, 20),
peers: peers.slice(0, 20),
cross_check: neighbourCrossCheck,
},
ix_presence: {
total_connections: ixConnections.length,
@ -2155,8 +2305,12 @@ const server = http.createServer(async (req, res) => {
description: p.description || "",
})),
},
data_quality: dataQuality,
};
// Update duration to include cross-check time
result.meta.duration_ms = Date.now() - start;
cacheSet(cacheKey, result, CACHE_TTL_LOOKUP);
res.end(JSON.stringify(result, null, 2));
} catch (err) {