refactor: extract Phase A modules from server.js (static data + pure logic)
Zero shared mutable state, no I/O -- behavior-preserving, verified via the smoke-test harness (28/28 endpoints match baseline). - server/data/*: CITY_COORDS, RIR_DELEGATION_URLS, TIER1_ASNS, IX_CITY_MAP, RIR country-code sets, UA constant - server/aspa-verification/engine.js: the RFC ASPA verification engine (hopCheck, verifyUpstream, verifyDownstream, detectValleys, etc.) - server/resilience-score.js, server/route-leak.js: pure scoring/heuristic functions server.js: 6838 -> 6454 lines. Part of the module-split plan at ~/.claude/plans/linked-riding-newell.md.
This commit is contained in:
parent
0e6fb4f2b8
commit
1ab6f54d10
420
server.js
420
server.js
@ -721,13 +721,7 @@ new Chart(document.getElementById('asnChart'), {
|
|||||||
let ipv6StatsCache = null;
|
let ipv6StatsCache = null;
|
||||||
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
|
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
||||||
const RIR_DELEGATION_URLS = {
|
const { RIR_DELEGATION_URLS } = require("./server/data/rir-delegation-urls");
|
||||||
RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
|
|
||||||
ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
|
|
||||||
APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
|
|
||||||
AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest',
|
|
||||||
LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and parse one RIR delegation file.
|
* Fetch and parse one RIR delegation file.
|
||||||
@ -804,32 +798,8 @@ async function fetchIpv6AdoptionStats(force) {
|
|||||||
// Pre-fetch on startup (non-blocking)
|
// Pre-fetch on startup (non-blocking)
|
||||||
fetchIpv6AdoptionStats().catch(() => {});
|
fetchIpv6AdoptionStats().catch(() => {});
|
||||||
|
|
||||||
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
|
const { UA } = require("./server/data/constants");
|
||||||
|
const { CITY_COORDS } = require("./server/data/city-coords");
|
||||||
// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
|
|
||||||
const CITY_COORDS = {
|
|
||||||
"amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821],
|
|
||||||
"paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417],
|
|
||||||
"berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820],
|
|
||||||
"vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122],
|
|
||||||
"copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384],
|
|
||||||
"milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393],
|
|
||||||
"dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025],
|
|
||||||
"sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784],
|
|
||||||
"moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198],
|
|
||||||
"hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093],
|
|
||||||
"los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298],
|
|
||||||
"dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874],
|
|
||||||
"seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832],
|
|
||||||
"sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940],
|
|
||||||
"manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735],
|
|
||||||
"nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052],
|
|
||||||
"auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903],
|
|
||||||
"atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207],
|
|
||||||
"montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780],
|
|
||||||
"taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456],
|
|
||||||
"scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// FEATURE 2: PDF Export — template generators + puppeteer renderer
|
// FEATURE 2: PDF Export — template generators + puppeteer renderer
|
||||||
@ -1538,30 +1508,7 @@ function cacheSet(key, data, ttlMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tier-1 ASN Set (used for route leak heuristics) ─────────────────────────
|
const { TIER1_ASNS } = require("./server/data/tier1-asns");
|
||||||
const TIER1_ASNS = new Set([
|
|
||||||
174, // Cogent
|
|
||||||
209, // CenturyLink/Lumen
|
|
||||||
286, // KPN
|
|
||||||
701, // Verizon/UUNET
|
|
||||||
702, // Verizon
|
|
||||||
1239, // Sprint
|
|
||||||
1273, // Vodafone
|
|
||||||
1280, // Internet Systems Consortium
|
|
||||||
1299, // Arelion (Telia)
|
|
||||||
2914, // NTT
|
|
||||||
3257, // GTT
|
|
||||||
3320, // Deutsche Telekom
|
|
||||||
3356, // Lumen (Level3)
|
|
||||||
3491, // PCCW
|
|
||||||
5511, // Orange
|
|
||||||
6453, // TATA
|
|
||||||
6461, // Zayo
|
|
||||||
6762, // Telecom Italia Sparkle
|
|
||||||
7018, // AT&T
|
|
||||||
7473, // SingTel
|
|
||||||
12956, // Telxius
|
|
||||||
]);
|
|
||||||
|
|
||||||
const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
|
const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
|
||||||
const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts
|
const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts
|
||||||
@ -2506,220 +2453,16 @@ async function validateRPKIWithCache(asn, prefix) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
const {
|
||||||
// RFC-Compliant ASPA Verification Engine
|
hasAsSet,
|
||||||
// ============================================================
|
hopCheck,
|
||||||
|
collapsePrepends,
|
||||||
// Check if AS path contains AS_SET segments (curly braces indicate sets)
|
verifyUpstream,
|
||||||
function hasAsSet(asPath) {
|
verifyDownstream,
|
||||||
if (typeof asPath === "string") {
|
detectValleys,
|
||||||
return asPath.includes("{") || asPath.includes("}");
|
buildAspaStore,
|
||||||
}
|
calculateAspaReadinessScore,
|
||||||
return false;
|
} = require("./server/aspa-verification/engine");
|
||||||
}
|
|
||||||
|
|
||||||
// Hop Check function (core of ASPA verification)
|
|
||||||
// aspaStore = Map<number, Set<number>> (CAS -> provider set)
|
|
||||||
// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
|
|
||||||
// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
|
|
||||||
// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
|
|
||||||
function hopCheck(asI, asJ, aspaStore) {
|
|
||||||
const providers = aspaStore.get(asI);
|
|
||||||
if (!providers) return "NoAttestation";
|
|
||||||
return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse AS path prepends (remove consecutive duplicates)
|
|
||||||
function collapsePrepends(path) {
|
|
||||||
return path.filter((as, i) => i === 0 || as !== path[i - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upstream Verification (RFC Section 6.1)
|
|
||||||
function verifyUpstream(asPath, aspaStore, rawPathStr) {
|
|
||||||
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
|
||||||
const collapsed = collapsePrepends(asPath);
|
|
||||||
if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };
|
|
||||||
|
|
||||||
const hops = [];
|
|
||||||
let hasNoAttestation = false;
|
|
||||||
|
|
||||||
for (let i = 1; i < collapsed.length; i++) {
|
|
||||||
// collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to
|
|
||||||
// the validator) is the provider it's declaring -- customer goes first in hopCheck.
|
|
||||||
const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
|
||||||
hops.push({
|
|
||||||
from: collapsed[i - 1],
|
|
||||||
to: collapsed[i],
|
|
||||||
result: check,
|
|
||||||
});
|
|
||||||
if (check === "NotProviderPlus") {
|
|
||||||
return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
|
|
||||||
}
|
|
||||||
if (check === "NoAttestation") hasNoAttestation = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: hasNoAttestation ? "Unknown" : "Valid",
|
|
||||||
reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
|
|
||||||
hops,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5.
|
|
||||||
// Unlike upstream verification, a downstream path may legitimately contain both
|
|
||||||
// an up-ramp (customer->provider hops, near the origin) AND a down-ramp
|
|
||||||
// (provider->customer hops, near the validator), transitioning at most once --
|
|
||||||
// e.g. origin -> its providers -> a peering exchange -> validator's providers ->
|
|
||||||
// validator. Section 5.3 defines four ramp lengths computed from two independent
|
|
||||||
// scans, verified 2026-07-16 against a worked example fetched from the draft
|
|
||||||
// (N=4 path with a single unattested/no-attestation "kink" correctly resolves
|
|
||||||
// to Valid, and a path with definite failures on both ends correctly resolves
|
|
||||||
// to Invalid, matching this implementation).
|
|
||||||
function verifyDownstream(asPath, aspaStore, rawPathStr) {
|
|
||||||
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
|
||||||
const collapsed = collapsePrepends(asPath);
|
|
||||||
const N = collapsed.length;
|
|
||||||
if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };
|
|
||||||
|
|
||||||
const hops = [];
|
|
||||||
for (let i = 1; i < N; i++) {
|
|
||||||
hops.push({
|
|
||||||
from: collapsed[i - 1],
|
|
||||||
to: collapsed[i],
|
|
||||||
result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
|
|
||||||
// (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
|
|
||||||
// maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
|
|
||||||
// minUpRamp stops at the first hop that isn't confirmed ProviderPlus
|
|
||||||
// (including unattested "NoAttestation" hops, which don't disprove a leak
|
|
||||||
// but also don't prove the path is clean).
|
|
||||||
let maxUpRamp = N;
|
|
||||||
let minUpRamp = N;
|
|
||||||
for (let k = N - 1; k >= 1; k--) {
|
|
||||||
const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore);
|
|
||||||
const I = N - k;
|
|
||||||
if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I;
|
|
||||||
if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I;
|
|
||||||
if (maxUpRamp !== N && minUpRamp !== N) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
|
|
||||||
// toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
|
|
||||||
let maxDownRamp = N;
|
|
||||||
let minDownRamp = N;
|
|
||||||
for (let k = 0; k <= N - 2; k++) {
|
|
||||||
const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore);
|
|
||||||
const rampLen = k + 1; // = N - J + 1 for the corresponding J
|
|
||||||
if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen;
|
|
||||||
if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen;
|
|
||||||
if (maxDownRamp !== N && minDownRamp !== N) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxUpRamp + maxDownRamp < N) {
|
|
||||||
return {
|
|
||||||
result: "Invalid",
|
|
||||||
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
|
|
||||||
hops,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (minUpRamp + minDownRamp < N) {
|
|
||||||
return {
|
|
||||||
result: "Unknown",
|
|
||||||
reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
|
|
||||||
hops,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
result: "Valid",
|
|
||||||
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
|
|
||||||
hops,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valley Detection: scan path for up-down-up pattern (route leak indicator)
|
|
||||||
function detectValleys(asPath, aspaStore) {
|
|
||||||
const collapsed = collapsePrepends(asPath);
|
|
||||||
if (collapsed.length < 4) return [];
|
|
||||||
|
|
||||||
const valleys = [];
|
|
||||||
// Walk the path and look at relationship transitions
|
|
||||||
const relationships = [];
|
|
||||||
for (let i = 1; i < collapsed.length; i++) {
|
|
||||||
const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
|
|
||||||
const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
|
||||||
let rel = "unknown";
|
|
||||||
if (fwd === "ProviderPlus") rel = "customer-to-provider";
|
|
||||||
else if (rev === "ProviderPlus") rel = "provider-to-customer";
|
|
||||||
else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
|
|
||||||
relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect c2p -> p2c -> c2p pattern
|
|
||||||
for (let i = 0; i < relationships.length - 2; i++) {
|
|
||||||
if (
|
|
||||||
relationships[i].rel === "customer-to-provider" &&
|
|
||||||
relationships[i + 1].rel === "provider-to-customer" &&
|
|
||||||
relationships[i + 2].rel === "customer-to-provider"
|
|
||||||
) {
|
|
||||||
valleys.push({
|
|
||||||
position: i,
|
|
||||||
path_segment: [
|
|
||||||
relationships[i].from,
|
|
||||||
relationships[i].to,
|
|
||||||
relationships[i + 1].to,
|
|
||||||
relationships[i + 2].to,
|
|
||||||
].map((a) => "AS" + a),
|
|
||||||
description:
|
|
||||||
"Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
|
|
||||||
" (c2p) -> AS" + relationships[i + 1].to +
|
|
||||||
" (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return valleys;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build ASPA store from detected provider relationships
|
|
||||||
function buildAspaStore(detectedProviders, targetAsn) {
|
|
||||||
const store = new Map();
|
|
||||||
// Add the target ASN's providers
|
|
||||||
if (detectedProviders.length > 0) {
|
|
||||||
const providerSet = new Set(detectedProviders.map((p) => p.asn));
|
|
||||||
store.set(targetAsn, providerSet);
|
|
||||||
}
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate ASPA Readiness Score (0-100)
|
|
||||||
function calculateAspaReadinessScore(params) {
|
|
||||||
const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;
|
|
||||||
|
|
||||||
// ROA coverage (0-25 points)
|
|
||||||
const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);
|
|
||||||
|
|
||||||
// ASPA object exists (0-25 points)
|
|
||||||
const aspaScore = aspaObjectExists ? 25 : 0;
|
|
||||||
|
|
||||||
// Provider completeness (0-25 points)
|
|
||||||
const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);
|
|
||||||
|
|
||||||
// Path validation results (0-25 points)
|
|
||||||
const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: roaScore + aspaScore + provScore + pathScore,
|
|
||||||
breakdown: {
|
|
||||||
roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
|
|
||||||
aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
|
|
||||||
provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
|
|
||||||
path_validation: { score: pathScore, max: 25, value: pathValidationPct },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -2897,132 +2640,8 @@ async function fetchTopology(targetAsn, depth) {
|
|||||||
// Feature 27: WHOIS via RIPE DB
|
// Feature 27: WHOIS via RIPE DB
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// ── Resilience Score ─────────────────────────────────────────────────────────
|
const { computeResilienceScore } = require("./server/resilience-score");
|
||||||
// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25%
|
const { computeRouteLeakDetection } = require("./server/route-leak");
|
||||||
// Hard cap at 5.0 when single transit provider detected.
|
|
||||||
// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
|
|
||||||
function computeResilienceScore(upstreams, peers, ixConnections, prefixes) {
|
|
||||||
const upstreamCount = upstreams.length;
|
|
||||||
const peerCount = peers.length;
|
|
||||||
const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length;
|
|
||||||
const prefixCount = prefixes.length;
|
|
||||||
|
|
||||||
// Transit Diversity (0-10)
|
|
||||||
let transitRaw = 0;
|
|
||||||
if (upstreamCount === 0) transitRaw = 0;
|
|
||||||
else if (upstreamCount === 1) transitRaw = 2;
|
|
||||||
else if (upstreamCount === 2) transitRaw = 5;
|
|
||||||
else if (upstreamCount <= 4) transitRaw = 7;
|
|
||||||
else transitRaw = 10;
|
|
||||||
|
|
||||||
// Peering Breadth (0-10)
|
|
||||||
let peeringRaw = 0;
|
|
||||||
if (peerCount >= 100) peeringRaw = 10;
|
|
||||||
else if (peerCount >= 50) peeringRaw = 8;
|
|
||||||
else if (peerCount >= 20) peeringRaw = 6;
|
|
||||||
else if (peerCount >= 5) peeringRaw = 4;
|
|
||||||
else if (peerCount >= 1) peeringRaw = 2;
|
|
||||||
|
|
||||||
// IXP Presence (0-10)
|
|
||||||
let ixpRaw = 0;
|
|
||||||
if (ixCount >= 10) ixpRaw = 10;
|
|
||||||
else if (ixCount >= 6) ixpRaw = 8;
|
|
||||||
else if (ixCount >= 3) ixpRaw = 6;
|
|
||||||
else if (ixCount >= 1) ixpRaw = 4;
|
|
||||||
|
|
||||||
// Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination
|
|
||||||
let pathRaw = 0;
|
|
||||||
if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10;
|
|
||||||
else if (upstreamCount >= 2) pathRaw = 7;
|
|
||||||
else if (ixCount >= 2) pathRaw = 6;
|
|
||||||
else if (upstreamCount === 1) pathRaw = 3;
|
|
||||||
else if (prefixCount > 0) pathRaw = 1;
|
|
||||||
|
|
||||||
const weighted =
|
|
||||||
transitRaw * 0.30 +
|
|
||||||
peeringRaw * 0.25 +
|
|
||||||
ixpRaw * 0.20 +
|
|
||||||
pathRaw * 0.25;
|
|
||||||
|
|
||||||
const singleTransitCap = upstreamCount === 1;
|
|
||||||
let score = Math.round(weighted * 10) / 10;
|
|
||||||
if (singleTransitCap) score = Math.min(score, 5.0);
|
|
||||||
score = Math.max(1.0, Math.min(10.0, score));
|
|
||||||
|
|
||||||
// Only return null if truly no data at all
|
|
||||||
if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
score,
|
|
||||||
breakdown: {
|
|
||||||
transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount },
|
|
||||||
peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount },
|
|
||||||
ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount },
|
|
||||||
path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount },
|
|
||||||
},
|
|
||||||
single_transit_cap_applied: singleTransitCap,
|
|
||||||
_provenance: {
|
|
||||||
source: "RIPE Stat asn-neighbours + PeeringDB netixlan",
|
|
||||||
validation: "cross-validated",
|
|
||||||
confidence: "high",
|
|
||||||
note: "All inputs independently validated daily against external sources",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Route Leak Detection ─────────────────────────────────────────────────────
|
|
||||||
// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data.
|
|
||||||
// NOT real-time. False positives possible for large networks with many Tier-1 relationships.
|
|
||||||
// Confidence: MEDIUM — pattern-based, not path-level analysis.
|
|
||||||
function computeRouteLeakDetection(upstreams, downstreams, peers) {
|
|
||||||
const upstreamAsns = new Set(upstreams.map(n => n.asn));
|
|
||||||
const downstreamAsns = new Set(downstreams.map(n => n.asn));
|
|
||||||
|
|
||||||
const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn));
|
|
||||||
const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn));
|
|
||||||
|
|
||||||
const patterns = [];
|
|
||||||
|
|
||||||
// Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate
|
|
||||||
const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn));
|
|
||||||
sandwich.forEach(n => {
|
|
||||||
patterns.push({
|
|
||||||
type: "sandwich_candidate",
|
|
||||||
asn: n.asn,
|
|
||||||
name: n.name,
|
|
||||||
description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s)
|
|
||||||
tier1Downstreams.forEach(n => {
|
|
||||||
if (!upstreamAsns.has(n.asn)) {
|
|
||||||
patterns.push({
|
|
||||||
type: "tier1_downstream",
|
|
||||||
asn: n.asn,
|
|
||||||
name: n.name,
|
|
||||||
description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const detected = patterns.length > 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
detected,
|
|
||||||
patterns,
|
|
||||||
tier1_upstream_count: tier1Upstreams.length,
|
|
||||||
tier1_downstream_count: tier1Downstreams.length,
|
|
||||||
_provenance: {
|
|
||||||
source: "RIPE Stat asn-neighbours",
|
|
||||||
validation: "heuristic",
|
|
||||||
confidence: "medium",
|
|
||||||
note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWhois(resource) {
|
async function fetchWhois(resource) {
|
||||||
const result = { resource, type: null, data: null, error: null };
|
const result = { resource, type: null, data: null, error: null };
|
||||||
@ -4957,10 +4576,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
// Last resort: derive RIR from country code (common assignments)
|
// Last resort: derive RIR from country code (common assignments)
|
||||||
if (!rir && country) {
|
if (!rir && country) {
|
||||||
const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]);
|
const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("./server/data/rir-country-codes");
|
||||||
const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]);
|
|
||||||
const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]);
|
|
||||||
const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]);
|
|
||||||
if (ARIN_CC.has(country)) rir = "ARIN";
|
if (ARIN_CC.has(country)) rir = "ARIN";
|
||||||
else if (APNIC_CC.has(country)) rir = "APNIC";
|
else if (APNIC_CC.has(country)) rir = "APNIC";
|
||||||
else if (LACNIC_CC.has(country)) rir = "LACNIC";
|
else if (LACNIC_CC.has(country)) rir = "LACNIC";
|
||||||
@ -5149,7 +4765,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Hard-coded IX ID → city for well-known IXPs whose names don't contain city
|
// Hard-coded IX ID → city for well-known IXPs whose names don't contain city
|
||||||
var IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
|
var { IX_CITY_MAP } = require("./server/data/ix-city-map");
|
||||||
ixConnections.forEach(function(conn) {
|
ixConnections.forEach(function(conn) {
|
||||||
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
||||||
var city = IX_CITY_MAP[conn.ix_id];
|
var city = IX_CITY_MAP[conn.ix_id];
|
||||||
|
|||||||
224
server/aspa-verification/engine.js
Normal file
224
server/aspa-verification/engine.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
// RFC-Compliant ASPA Verification Engine
|
||||||
|
// Pure functions, no I/O -- see draft-ietf-sidrops-aspa-verification.
|
||||||
|
|
||||||
|
// Check if AS path contains AS_SET segments (curly braces indicate sets)
|
||||||
|
function hasAsSet(asPath) {
|
||||||
|
if (typeof asPath === "string") {
|
||||||
|
return asPath.includes("{") || asPath.includes("}");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hop Check function (core of ASPA verification)
|
||||||
|
// aspaStore = Map<number, Set<number>> (CAS -> provider set)
|
||||||
|
// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
|
||||||
|
// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
|
||||||
|
// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
|
||||||
|
function hopCheck(asI, asJ, aspaStore) {
|
||||||
|
const providers = aspaStore.get(asI);
|
||||||
|
if (!providers) return "NoAttestation";
|
||||||
|
return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse AS path prepends (remove consecutive duplicates)
|
||||||
|
function collapsePrepends(path) {
|
||||||
|
return path.filter((as, i) => i === 0 || as !== path[i - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream Verification (RFC Section 6.1)
|
||||||
|
function verifyUpstream(asPath, aspaStore, rawPathStr) {
|
||||||
|
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
||||||
|
const collapsed = collapsePrepends(asPath);
|
||||||
|
if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };
|
||||||
|
|
||||||
|
const hops = [];
|
||||||
|
let hasNoAttestation = false;
|
||||||
|
|
||||||
|
for (let i = 1; i < collapsed.length; i++) {
|
||||||
|
// collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to
|
||||||
|
// the validator) is the provider it's declaring -- customer goes first in hopCheck.
|
||||||
|
const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
||||||
|
hops.push({
|
||||||
|
from: collapsed[i - 1],
|
||||||
|
to: collapsed[i],
|
||||||
|
result: check,
|
||||||
|
});
|
||||||
|
if (check === "NotProviderPlus") {
|
||||||
|
return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
|
||||||
|
}
|
||||||
|
if (check === "NoAttestation") hasNoAttestation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: hasNoAttestation ? "Unknown" : "Valid",
|
||||||
|
reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
|
||||||
|
hops,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5.
|
||||||
|
// Unlike upstream verification, a downstream path may legitimately contain both
|
||||||
|
// an up-ramp (customer->provider hops, near the origin) AND a down-ramp
|
||||||
|
// (provider->customer hops, near the validator), transitioning at most once --
|
||||||
|
// e.g. origin -> its providers -> a peering exchange -> validator's providers ->
|
||||||
|
// validator. Section 5.3 defines four ramp lengths computed from two independent
|
||||||
|
// scans, verified 2026-07-16 against a worked example fetched from the draft
|
||||||
|
// (N=4 path with a single unattested/no-attestation "kink" correctly resolves
|
||||||
|
// to Valid, and a path with definite failures on both ends correctly resolves
|
||||||
|
// to Invalid, matching this implementation).
|
||||||
|
function verifyDownstream(asPath, aspaStore, rawPathStr) {
|
||||||
|
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
||||||
|
const collapsed = collapsePrepends(asPath);
|
||||||
|
const N = collapsed.length;
|
||||||
|
if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };
|
||||||
|
|
||||||
|
const hops = [];
|
||||||
|
for (let i = 1; i < N; i++) {
|
||||||
|
hops.push({
|
||||||
|
from: collapsed[i - 1],
|
||||||
|
to: collapsed[i],
|
||||||
|
result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
|
||||||
|
// (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
|
||||||
|
// maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
|
||||||
|
// minUpRamp stops at the first hop that isn't confirmed ProviderPlus
|
||||||
|
// (including unattested "NoAttestation" hops, which don't disprove a leak
|
||||||
|
// but also don't prove the path is clean).
|
||||||
|
let maxUpRamp = N;
|
||||||
|
let minUpRamp = N;
|
||||||
|
for (let k = N - 1; k >= 1; k--) {
|
||||||
|
const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore);
|
||||||
|
const I = N - k;
|
||||||
|
if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I;
|
||||||
|
if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I;
|
||||||
|
if (maxUpRamp !== N && minUpRamp !== N) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
|
||||||
|
// toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
|
||||||
|
let maxDownRamp = N;
|
||||||
|
let minDownRamp = N;
|
||||||
|
for (let k = 0; k <= N - 2; k++) {
|
||||||
|
const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore);
|
||||||
|
const rampLen = k + 1; // = N - J + 1 for the corresponding J
|
||||||
|
if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen;
|
||||||
|
if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen;
|
||||||
|
if (maxDownRamp !== N && minDownRamp !== N) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxUpRamp + maxDownRamp < N) {
|
||||||
|
return {
|
||||||
|
result: "Invalid",
|
||||||
|
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
|
||||||
|
hops,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (minUpRamp + minDownRamp < N) {
|
||||||
|
return {
|
||||||
|
result: "Unknown",
|
||||||
|
reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
|
||||||
|
hops,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
result: "Valid",
|
||||||
|
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
|
||||||
|
hops,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valley Detection: scan path for up-down-up pattern (route leak indicator)
|
||||||
|
function detectValleys(asPath, aspaStore) {
|
||||||
|
const collapsed = collapsePrepends(asPath);
|
||||||
|
if (collapsed.length < 4) return [];
|
||||||
|
|
||||||
|
const valleys = [];
|
||||||
|
// Walk the path and look at relationship transitions
|
||||||
|
const relationships = [];
|
||||||
|
for (let i = 1; i < collapsed.length; i++) {
|
||||||
|
const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
|
||||||
|
const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
||||||
|
let rel = "unknown";
|
||||||
|
if (fwd === "ProviderPlus") rel = "customer-to-provider";
|
||||||
|
else if (rev === "ProviderPlus") rel = "provider-to-customer";
|
||||||
|
else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
|
||||||
|
relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect c2p -> p2c -> c2p pattern
|
||||||
|
for (let i = 0; i < relationships.length - 2; i++) {
|
||||||
|
if (
|
||||||
|
relationships[i].rel === "customer-to-provider" &&
|
||||||
|
relationships[i + 1].rel === "provider-to-customer" &&
|
||||||
|
relationships[i + 2].rel === "customer-to-provider"
|
||||||
|
) {
|
||||||
|
valleys.push({
|
||||||
|
position: i,
|
||||||
|
path_segment: [
|
||||||
|
relationships[i].from,
|
||||||
|
relationships[i].to,
|
||||||
|
relationships[i + 1].to,
|
||||||
|
relationships[i + 2].to,
|
||||||
|
].map((a) => "AS" + a),
|
||||||
|
description:
|
||||||
|
"Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
|
||||||
|
" (c2p) -> AS" + relationships[i + 1].to +
|
||||||
|
" (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return valleys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ASPA store from detected provider relationships
|
||||||
|
function buildAspaStore(detectedProviders, targetAsn) {
|
||||||
|
const store = new Map();
|
||||||
|
// Add the target ASN's providers
|
||||||
|
if (detectedProviders.length > 0) {
|
||||||
|
const providerSet = new Set(detectedProviders.map((p) => p.asn));
|
||||||
|
store.set(targetAsn, providerSet);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate ASPA Readiness Score (0-100)
|
||||||
|
function calculateAspaReadinessScore(params) {
|
||||||
|
const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;
|
||||||
|
|
||||||
|
// ROA coverage (0-25 points)
|
||||||
|
const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);
|
||||||
|
|
||||||
|
// ASPA object exists (0-25 points)
|
||||||
|
const aspaScore = aspaObjectExists ? 25 : 0;
|
||||||
|
|
||||||
|
// Provider completeness (0-25 points)
|
||||||
|
const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);
|
||||||
|
|
||||||
|
// Path validation results (0-25 points)
|
||||||
|
const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: roaScore + aspaScore + provScore + pathScore,
|
||||||
|
breakdown: {
|
||||||
|
roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
|
||||||
|
aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
|
||||||
|
provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
|
||||||
|
path_validation: { score: pathScore, max: 25, value: pathValidationPct },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hasAsSet,
|
||||||
|
hopCheck,
|
||||||
|
collapsePrepends,
|
||||||
|
verifyUpstream,
|
||||||
|
verifyDownstream,
|
||||||
|
detectValleys,
|
||||||
|
buildAspaStore,
|
||||||
|
calculateAspaReadinessScore,
|
||||||
|
};
|
||||||
26
server/data/city-coords.js
Normal file
26
server/data/city-coords.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
|
||||||
|
const CITY_COORDS = {
|
||||||
|
"amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821],
|
||||||
|
"paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417],
|
||||||
|
"berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820],
|
||||||
|
"vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122],
|
||||||
|
"copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384],
|
||||||
|
"milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393],
|
||||||
|
"dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025],
|
||||||
|
"sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784],
|
||||||
|
"moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198],
|
||||||
|
"hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093],
|
||||||
|
"los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298],
|
||||||
|
"dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874],
|
||||||
|
"seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832],
|
||||||
|
"sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940],
|
||||||
|
"manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735],
|
||||||
|
"nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052],
|
||||||
|
"auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903],
|
||||||
|
"atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207],
|
||||||
|
"montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780],
|
||||||
|
"taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456],
|
||||||
|
"scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { CITY_COORDS };
|
||||||
3
server/data/constants.js
Normal file
3
server/data/constants.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
|
||||||
|
|
||||||
|
module.exports = { UA };
|
||||||
4
server/data/ix-city-map.js
Normal file
4
server/data/ix-city-map.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Small ASN/IX-id → city fallback map used by the /api/lookup facility geocoder
|
||||||
|
const IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
|
||||||
|
|
||||||
|
module.exports = { IX_CITY_MAP };
|
||||||
6
server/data/rir-country-codes.js
Normal file
6
server/data/rir-country-codes.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]);
|
||||||
|
const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]);
|
||||||
|
const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]);
|
||||||
|
const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]);
|
||||||
|
|
||||||
|
module.exports = { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC };
|
||||||
9
server/data/rir-delegation-urls.js
Normal file
9
server/data/rir-delegation-urls.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const RIR_DELEGATION_URLS = {
|
||||||
|
RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
|
||||||
|
ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
|
||||||
|
APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
|
||||||
|
AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest',
|
||||||
|
LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { RIR_DELEGATION_URLS };
|
||||||
26
server/data/tier1-asns.js
Normal file
26
server/data/tier1-asns.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Tier-1 ASN Set (used for route leak heuristics)
|
||||||
|
const TIER1_ASNS = new Set([
|
||||||
|
174, // Cogent
|
||||||
|
209, // CenturyLink/Lumen
|
||||||
|
286, // KPN
|
||||||
|
701, // Verizon/UUNET
|
||||||
|
702, // Verizon
|
||||||
|
1239, // Sprint
|
||||||
|
1273, // Vodafone
|
||||||
|
1280, // Internet Systems Consortium
|
||||||
|
1299, // Arelion (Telia)
|
||||||
|
2914, // NTT
|
||||||
|
3257, // GTT
|
||||||
|
3320, // Deutsche Telekom
|
||||||
|
3356, // Lumen (Level3)
|
||||||
|
3491, // PCCW
|
||||||
|
5511, // Orange
|
||||||
|
6453, // TATA
|
||||||
|
6461, // Zayo
|
||||||
|
6762, // Telecom Italia Sparkle
|
||||||
|
7018, // AT&T
|
||||||
|
7473, // SingTel
|
||||||
|
12956, // Telxius
|
||||||
|
]);
|
||||||
|
|
||||||
|
module.exports = { TIER1_ASNS };
|
||||||
75
server/resilience-score.js
Normal file
75
server/resilience-score.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25%
|
||||||
|
// Hard cap at 5.0 when single transit provider detected.
|
||||||
|
// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
|
||||||
|
function computeResilienceScore(upstreams, peers, ixConnections, prefixes) {
|
||||||
|
const upstreamCount = upstreams.length;
|
||||||
|
const peerCount = peers.length;
|
||||||
|
const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length;
|
||||||
|
const prefixCount = prefixes.length;
|
||||||
|
|
||||||
|
// Transit Diversity (0-10)
|
||||||
|
let transitRaw = 0;
|
||||||
|
if (upstreamCount === 0) transitRaw = 0;
|
||||||
|
else if (upstreamCount === 1) transitRaw = 2;
|
||||||
|
else if (upstreamCount === 2) transitRaw = 5;
|
||||||
|
else if (upstreamCount <= 4) transitRaw = 7;
|
||||||
|
else transitRaw = 10;
|
||||||
|
|
||||||
|
// Peering Breadth (0-10)
|
||||||
|
let peeringRaw = 0;
|
||||||
|
if (peerCount >= 100) peeringRaw = 10;
|
||||||
|
else if (peerCount >= 50) peeringRaw = 8;
|
||||||
|
else if (peerCount >= 20) peeringRaw = 6;
|
||||||
|
else if (peerCount >= 5) peeringRaw = 4;
|
||||||
|
else if (peerCount >= 1) peeringRaw = 2;
|
||||||
|
|
||||||
|
// IXP Presence (0-10)
|
||||||
|
let ixpRaw = 0;
|
||||||
|
if (ixCount >= 10) ixpRaw = 10;
|
||||||
|
else if (ixCount >= 6) ixpRaw = 8;
|
||||||
|
else if (ixCount >= 3) ixpRaw = 6;
|
||||||
|
else if (ixCount >= 1) ixpRaw = 4;
|
||||||
|
|
||||||
|
// Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination
|
||||||
|
let pathRaw = 0;
|
||||||
|
if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10;
|
||||||
|
else if (upstreamCount >= 2) pathRaw = 7;
|
||||||
|
else if (ixCount >= 2) pathRaw = 6;
|
||||||
|
else if (upstreamCount === 1) pathRaw = 3;
|
||||||
|
else if (prefixCount > 0) pathRaw = 1;
|
||||||
|
|
||||||
|
const weighted =
|
||||||
|
transitRaw * 0.30 +
|
||||||
|
peeringRaw * 0.25 +
|
||||||
|
ixpRaw * 0.20 +
|
||||||
|
pathRaw * 0.25;
|
||||||
|
|
||||||
|
const singleTransitCap = upstreamCount === 1;
|
||||||
|
let score = Math.round(weighted * 10) / 10;
|
||||||
|
if (singleTransitCap) score = Math.min(score, 5.0);
|
||||||
|
score = Math.max(1.0, Math.min(10.0, score));
|
||||||
|
|
||||||
|
// Only return null if truly no data at all
|
||||||
|
if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
breakdown: {
|
||||||
|
transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount },
|
||||||
|
peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount },
|
||||||
|
ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount },
|
||||||
|
path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount },
|
||||||
|
},
|
||||||
|
single_transit_cap_applied: singleTransitCap,
|
||||||
|
_provenance: {
|
||||||
|
source: "RIPE Stat asn-neighbours + PeeringDB netixlan",
|
||||||
|
validation: "cross-validated",
|
||||||
|
confidence: "high",
|
||||||
|
note: "All inputs independently validated daily against external sources",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { computeResilienceScore };
|
||||||
54
server/route-leak.js
Normal file
54
server/route-leak.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
const { TIER1_ASNS } = require("./data/tier1-asns");
|
||||||
|
|
||||||
|
// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data.
|
||||||
|
// NOT real-time. False positives possible for large networks with many Tier-1 relationships.
|
||||||
|
// Confidence: MEDIUM — pattern-based, not path-level analysis.
|
||||||
|
function computeRouteLeakDetection(upstreams, downstreams, peers) {
|
||||||
|
const upstreamAsns = new Set(upstreams.map(n => n.asn));
|
||||||
|
const downstreamAsns = new Set(downstreams.map(n => n.asn));
|
||||||
|
|
||||||
|
const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn));
|
||||||
|
const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn));
|
||||||
|
|
||||||
|
const patterns = [];
|
||||||
|
|
||||||
|
// Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate
|
||||||
|
const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn));
|
||||||
|
sandwich.forEach(n => {
|
||||||
|
patterns.push({
|
||||||
|
type: "sandwich_candidate",
|
||||||
|
asn: n.asn,
|
||||||
|
name: n.name,
|
||||||
|
description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s)
|
||||||
|
tier1Downstreams.forEach(n => {
|
||||||
|
if (!upstreamAsns.has(n.asn)) {
|
||||||
|
patterns.push({
|
||||||
|
type: "tier1_downstream",
|
||||||
|
asn: n.asn,
|
||||||
|
name: n.name,
|
||||||
|
description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const detected = patterns.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
detected,
|
||||||
|
patterns,
|
||||||
|
tier1_upstream_count: tier1Upstreams.length,
|
||||||
|
tier1_downstream_count: tier1Downstreams.length,
|
||||||
|
_provenance: {
|
||||||
|
source: "RIPE Stat asn-neighbours",
|
||||||
|
validation: "heuristic",
|
||||||
|
confidence: "medium",
|
||||||
|
note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { computeRouteLeakDetection };
|
||||||
Loading…
x
Reference in New Issue
Block a user