Merge branch 'refactor/server-js-split' into main
Some checks failed
build-verify / build-verify (push) Failing after 9s
Some checks failed
build-verify / build-verify (push) Failing after 9s
Splits server.js (6838 lines) into server/*.js modules per the 800-line project limit. Behavior-preserving except 5 pre-existing bugs fixed along the way (see PR #3 for details): compare's missing fetchRPKIPerPrefix, webhooks' undefined `secret` reference, health's DB-stats-failure closure bug, PeeringDB Authorization:undefined crash, RESULT_CACHE_TTL export regression. server.js: 6838 -> 578 lines. Verified via node --check, eslint no-undef, and a 28-endpoint smoke-test diff at every step. Closes gitea#3.
This commit is contained in:
commit
8d5f2fdd97
3505
server/__smoke__/baseline/baseline.json
Normal file
3505
server/__smoke__/baseline/baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
294
server/__smoke__/compare-responses.js
Normal file
294
server/__smoke__/compare-responses.js
Normal file
@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env node
|
||||
// Regression gate for the server.js module-extraction refactor (see
|
||||
// /Users/renefichtmueller/.claude/plans/linked-riding-newell.md). Not shipped
|
||||
// to prod -- local dev tool only.
|
||||
//
|
||||
// Usage:
|
||||
// node server/__smoke__/compare-responses.js capture # save baseline (run once, before Phase A)
|
||||
// node server/__smoke__/compare-responses.js diff # run after every extraction step
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn, execFileSync } = require("child_process");
|
||||
|
||||
// server.js writes real snapshots into these git-tracked files at startup
|
||||
// (ASPA adoption history) and at runtime (hijack alerts, webhook subs) --
|
||||
// snapshot + restore around every local boot so smoke-testing never leaves
|
||||
// lasting mutations on data that's supposed to hold genuine production
|
||||
// history, not local test noise.
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..");
|
||||
const MUTABLE_TRACKED_FILES = ["aspa-adoption-history.json", "hijack-alerts.json", "webhook-subs.json"];
|
||||
|
||||
function restoreTrackedFiles() {
|
||||
try {
|
||||
execFileSync("git", ["checkout", "--", ...MUTABLE_TRACKED_FILES], { cwd: REPO_ROOT, stdio: "ignore" });
|
||||
} catch {
|
||||
// fine if a file has no uncommitted changes (git checkout on a clean path is a no-op anyway)
|
||||
}
|
||||
}
|
||||
|
||||
const PORT = 3199;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const BASELINE_DIR = path.join(__dirname, "baseline");
|
||||
const ASN = 13335; // Cloudflare -- stable, public, always has real data
|
||||
const ASN2 = 15169; // Google -- for compare/relationships
|
||||
|
||||
// One representative, read-only (GET) request per route from the exploration
|
||||
// map. POST/DELETE mutating routes (feedback POST, webhooks POST/DELETE,
|
||||
// hijacks resolve) are intentionally excluded -- not idempotent-safe to
|
||||
// re-run repeatedly across every extraction step.
|
||||
const ENDPOINTS = [
|
||||
["root", "/"],
|
||||
["search", `/api/search?q=cloudflare`],
|
||||
["visitors", "/api/visitors"],
|
||||
["health", "/api/health"],
|
||||
["aspa-verify", `/api/aspa/verify?asn=${ASN}`],
|
||||
["aspa-legacy", `/api/aspa?asn=${ASN}`],
|
||||
["bgp", `/api/bgp?asn=${ASN}`],
|
||||
["validate", `/api/validate?asn=${ASN}`],
|
||||
["lookup", `/api/lookup?asn=${ASN}`],
|
||||
["relationships", `/api/relationships?asn=${ASN}`],
|
||||
["compare", `/api/compare?asn1=${ASN}&asn2=${ASN2}`],
|
||||
["quick-ix", `/api/quick-ix?asn=${ASN}`],
|
||||
["peers-find", `/api/peers/find?asn=${ASN}`],
|
||||
["prefix-detail", "/api/prefix/detail?prefix=1.1.1.0/24"],
|
||||
["ix-detail", "/api/ix/detail?asn=${ASN}"],
|
||||
["topology", `/api/topology?asn=${ASN}`],
|
||||
["whois", `/api/whois?asn=${ASN}`],
|
||||
["enrich", `/api/enrich?asn=${ASN}`],
|
||||
["changelog", "/changelog"],
|
||||
["webhooks-list", "/api/webhooks"],
|
||||
["hijacks-list", "/api/hijacks"],
|
||||
["hijack-alerts", "/api/hijack-alerts"],
|
||||
["aspa-adoption-page", "/aspa-adoption"],
|
||||
["aspa-adoption-stats", "/api/aspa-adoption-stats?period=30d"],
|
||||
["ipv6-adoption-stats", "/api/ipv6-adoption-stats"],
|
||||
["atlas-coverage", "/api/atlas/coverage"],
|
||||
// proxy stubs -- expected to fail identically (peercortex-api not running
|
||||
// locally either); still worth diffing so the *error shape* stays stable
|
||||
["submarine-cables", "/api/submarine-cables"],
|
||||
["communities", "/api/communities?asn=" + ASN],
|
||||
];
|
||||
|
||||
// Fields that legitimately change between runs -- excluded from the diff.
|
||||
// Two sources of real (non-bug) drift observed empirically by diffing an
|
||||
// unmodified server.js against itself: (1) process-local counters (uptime,
|
||||
// memory), and (2) live third-party data that changes minute-to-minute
|
||||
// (RIPE Atlas probe counts/IDs, Cloudflare RPKI feed size, health_score
|
||||
// insofar as it's derived from live external checks like MANRS that can
|
||||
// time out differently run to run).
|
||||
const NONDETERMINISTIC_KEY_PATTERN =
|
||||
/^(generated_at|timestamp|checked_at|fetched_at|query_time|cache_age|age_seconds|age_minutes|uptime_seconds|memory_mb|response_time_ms|duration_ms|last_updated|health_score|delta_last|history_samples|total_probes|total_connected|unique_asns_with_probes|atlas_asns_total|atlas_asns_with_aspa|coverage_pct_atlas|asns_with_probes|roa_count|aspa_objects)$/i;
|
||||
|
||||
// Whole sub-trees excluded by dotted path (relative to the endpoint's body),
|
||||
// found empirically by diffing an unmodified server.js against itself twice.
|
||||
// Each of these depends on a live third-party call that can legitimately
|
||||
// time out or return fresh-but-different data run to run (RIPE Stat routing
|
||||
// status, RIS prefix history, PeeringDB unauthenticated rate limits, RIPE
|
||||
// Atlas probe churn, source-fetch timing diagnostics) -- none of it reflects
|
||||
// server.js's own logic, so a diff here is noise, not a regression signal.
|
||||
const VOLATILE_PATHS = new Set([
|
||||
"lookup.source_timing",
|
||||
"validate.validations.visibility",
|
||||
// redundant summary of validations.* (already diffed in detail) that also
|
||||
// embeds the visibility check's flaky pass/info status
|
||||
"validate.score_breakdown",
|
||||
"prefix-detail.origins",
|
||||
"prefix-detail.first_seen",
|
||||
"aspa-adoption-stats.forecast",
|
||||
"quick-ix.ix_connections",
|
||||
"quick-ix.name",
|
||||
"atlas-coverage.by_country",
|
||||
// live Cloudflare RPKI feed object count ticks between runs; date-rollover
|
||||
// (midnight) shifts the adoption tracker's day-over-day delta too
|
||||
"health.caches.aspa_map.entries",
|
||||
"health.aspa_adoption.total_objects",
|
||||
"aspa-adoption-stats.current.delta_from_previous",
|
||||
// RIR delegation files (RIPE/ARIN/APNIC/AFRINIC/LACNIC) update near-real-time
|
||||
"ipv6-adoption-stats.global_ipv6_pct",
|
||||
"ipv6-adoption-stats.total_ipv4_records",
|
||||
"ipv6-adoption-stats.total_ipv6_records",
|
||||
"ipv6-adoption-stats.by_rir",
|
||||
]);
|
||||
|
||||
function stripNondeterministic(obj, pathPrefix) {
|
||||
if (Array.isArray(obj)) return obj.map((v) => stripNondeterministic(v, pathPrefix));
|
||||
if (obj && typeof obj === "object") {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (NONDETERMINISTIC_KEY_PATTERN.test(k)) continue;
|
||||
const fullPath = pathPrefix ? `${pathPrefix}.${k}` : k;
|
||||
if (VOLATILE_PATHS.has(fullPath)) continue;
|
||||
out[k] = stripNondeterministic(v, fullPath);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function fetchOne(name, pathAndQuery) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(BASE + pathAndQuery, { timeout: 20000 }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
parsed = { __non_json__: true, status: res.statusCode, bodyLength: body.length };
|
||||
}
|
||||
resolve({ status: res.statusCode, body: stripNondeterministic(parsed, name) });
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ status: 0, body: { __timeout__: true } });
|
||||
});
|
||||
req.on("error", (err) => resolve({ status: 0, body: { __error__: err.message } }));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServer(maxWaitMs = 100000) {
|
||||
// Startup does real, sequential network fetches (Cloudflare RPKI/ROA feed
|
||||
// ~972k entries, full paginated RIPE Atlas probe list, IPv6 RIR delegation
|
||||
// stats) before calling server.listen() -- observed ~60s locally.
|
||||
const start = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryOnce = () => {
|
||||
const req = http.get(`${BASE}/api/health`, { timeout: 2000 }, (res) => {
|
||||
res.resume();
|
||||
resolve();
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time"));
|
||||
setTimeout(tryOnce, 500);
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time"));
|
||||
setTimeout(tryOnce, 500);
|
||||
});
|
||||
};
|
||||
tryOnce();
|
||||
});
|
||||
}
|
||||
|
||||
async function runAgainstLiveServer() {
|
||||
const results = {};
|
||||
for (const [name, pathAndQuery] of ENDPOINTS) {
|
||||
results[name] = await fetchOne(name, pathAndQuery);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function leafDiffs(a, b, pathPrefix, out) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) return;
|
||||
const bothPlainObjects =
|
||||
a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b);
|
||||
if (bothPlainObjects) {
|
||||
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
leafDiffs(a[k], b[k], pathPrefix + "." + k, out);
|
||||
}
|
||||
} else {
|
||||
out.push(`${pathPrefix}: ${JSON.stringify(a)} -> ${JSON.stringify(b)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function diffResults(baseline, current) {
|
||||
const diffs = [];
|
||||
for (const [name] of ENDPOINTS) {
|
||||
const b = JSON.stringify(baseline[name]);
|
||||
const c = JSON.stringify(current[name]);
|
||||
if (b !== c) {
|
||||
const leaves = [];
|
||||
leafDiffs(baseline[name], current[name], name, leaves);
|
||||
diffs.push({ name, leaves });
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2];
|
||||
if (mode !== "capture" && mode !== "diff") {
|
||||
console.error("Usage: node compare-responses.js <capture|diff>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
restoreTrackedFiles();
|
||||
|
||||
const serverEntry = path.join(__dirname, "..", "..", "server.js");
|
||||
const child = spawn(process.execPath, [serverEntry], {
|
||||
cwd: path.join(__dirname, "..", ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(PORT),
|
||||
// Node 22's stricter header validation throws on Authorization:
|
||||
// undefined, which server.js's fetchPdbOrgCountries() produces when
|
||||
// PEERINGDB_API_KEY is unset (line ~6689). This is a real bug (tracked
|
||||
// separately, fixed during the pdb-org-countries.js extraction in
|
||||
// Phase B) but it blocks the server from even booting for local
|
||||
// testing -- work around it here at the test-harness level only, so
|
||||
// the baseline reflects real behavior rather than a crash.
|
||||
PEERINGDB_API_KEY: process.env.PEERINGDB_API_KEY || "smoke-test-placeholder",
|
||||
// Force the proxy-to-peercortex-api path to genuinely fail closed
|
||||
// instead of cross-talking with whatever unrelated dev server happens
|
||||
// to be listening on the real default port (3102) on this machine --
|
||||
// observed proxying straight into an unrelated Next.js app's 404 page.
|
||||
API_SERVER_PORT: "39102",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderrBuf = "";
|
||||
child.stderr.on("data", (c) => (stderrBuf += c));
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {}
|
||||
};
|
||||
process.on("exit", cleanup);
|
||||
|
||||
try {
|
||||
await waitForServer();
|
||||
console.log(`Server up on port ${PORT}, running ${ENDPOINTS.length} requests...`);
|
||||
const results = await runAgainstLiveServer();
|
||||
|
||||
if (mode === "capture") {
|
||||
fs.mkdirSync(BASELINE_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(BASELINE_DIR, "baseline.json"), JSON.stringify(results, null, 2));
|
||||
console.log(`Baseline captured: ${ENDPOINTS.length} endpoints -> ${BASELINE_DIR}/baseline.json`);
|
||||
} else {
|
||||
const baselinePath = path.join(BASELINE_DIR, "baseline.json");
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
console.error("No baseline found -- run `capture` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
||||
const diffs = diffResults(baseline, results);
|
||||
if (diffs.length === 0) {
|
||||
console.log(`PASS -- all ${ENDPOINTS.length} endpoints match baseline.`);
|
||||
} else {
|
||||
console.log(`FAIL -- ${diffs.length}/${ENDPOINTS.length} endpoint(s) differ from baseline:`);
|
||||
for (const d of diffs) {
|
||||
console.log(`\n=== ${d.name} (${d.leaves.length} leaf change(s)) ===`);
|
||||
for (const line of d.leaves.slice(0, 20)) console.log(" " + line);
|
||||
if (d.leaves.length > 20) console.log(` ... and ${d.leaves.length - 20} more`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Smoke test failed to run:", err.message);
|
||||
if (stderrBuf) console.error("--- server stderr ---\n" + stderrBuf.slice(0, 3000));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
cleanup();
|
||||
restoreTrackedFiles();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
291
server/aspa-adoption/tracker.js
Normal file
291
server/aspa-adoption/tracker.js
Normal file
@ -0,0 +1,291 @@
|
||||
const fs = require("fs");
|
||||
const { DATA_DIR } = require("../hijack-monitoring/store");
|
||||
const { atlasState } = require("../atlas-probes");
|
||||
|
||||
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
|
||||
|
||||
// Load persisted ASPA adoption history from disk.
|
||||
// Returns array of snapshot objects (up to 365 entries).
|
||||
function loadAspaAdoptionHistory() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch(_) { return []; }
|
||||
}
|
||||
|
||||
// Save ASPA adoption history to disk (keep last 365 entries).
|
||||
function saveAspaAdoptionHistory(history) {
|
||||
try {
|
||||
const trimmed = history.slice(-365);
|
||||
fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
|
||||
} catch(e) {
|
||||
console.error('[ASPA-ADOPT] Save failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory adoption history (persisted on each new snapshot). Mutated
|
||||
// in place (push / index-assign), never reassigned -- safe to export as a
|
||||
// live array reference, same pattern as roaStore/pdbSourceCache.
|
||||
let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
|
||||
|
||||
// Take a new adoption snapshot and persist it. Called whenever the RPKI feed
|
||||
// is refreshed.
|
||||
// @param {number} aspaCount — total ASPA objects in rpkiAspaMap
|
||||
// @param {number} roaCount — total ROAs in roaStore
|
||||
// @param {Map} rpkiAspaMap — customer_asid -> Set<provider_asn>, owned by
|
||||
// server/services/rpki.js; passed in explicitly (rather than required here)
|
||||
// to avoid a circular require between this module and rpki.js.
|
||||
function recordAspaAdoptionSnapshot(aspaCount, roaCount, rpkiAspaMap) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
|
||||
|
||||
// Compute how many Atlas-visible ASNs have ASPA
|
||||
let atlasTotal = 0;
|
||||
let atlasWithAspa = 0;
|
||||
if (atlasState.cache?.asns_with_probes) {
|
||||
atlasTotal = atlasState.cache.asns_with_probes.length;
|
||||
// rpkiAspaMap is keyed by integer ASN
|
||||
atlasWithAspa = atlasState.cache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
|
||||
}
|
||||
|
||||
const coveragePct = atlasTotal > 0
|
||||
? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
|
||||
: 0;
|
||||
|
||||
// Compute delta from previous snapshot
|
||||
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||
const delta = prev ? aspaCount - prev.aspa_objects : 0;
|
||||
|
||||
const snapshot = {
|
||||
date,
|
||||
ts: now.getTime(),
|
||||
aspa_objects: aspaCount,
|
||||
roa_count: roaCount,
|
||||
atlas_asns_total: atlasTotal,
|
||||
atlas_asns_with_aspa: atlasWithAspa,
|
||||
coverage_pct_atlas: coveragePct,
|
||||
aspa_delta: delta,
|
||||
method: 'rpki_cloudflare_feed',
|
||||
};
|
||||
|
||||
// Only store one snapshot per date (overwrite if same day)
|
||||
const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
|
||||
if (existingIdx >= 0) {
|
||||
aspaAdoptionDailyHistory[existingIdx] = snapshot;
|
||||
} else {
|
||||
aspaAdoptionDailyHistory.push(snapshot);
|
||||
}
|
||||
saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
|
||||
console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
|
||||
function getAdoptionTrend(period) {
|
||||
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
|
||||
}
|
||||
|
||||
// Build the ASPA adoption dashboard HTML.
|
||||
function buildAspaAdoptionDashboard() {
|
||||
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||
const trend30 = getAdoptionTrend('30d');
|
||||
const trend7 = getAdoptionTrend('7d');
|
||||
|
||||
// Forecast: simple linear regression over last 30 days
|
||||
function linearForecast(data, daysAhead) {
|
||||
if (data.length < 2) return null;
|
||||
const n = data.length;
|
||||
const xs = data.map((_, i) => i);
|
||||
const ys = data.map(d => d.coverage_pct_atlas);
|
||||
const xMean = xs.reduce((a, b) => a + b, 0) / n;
|
||||
const yMean = ys.reduce((a, b) => a + b, 0) / n;
|
||||
const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0);
|
||||
const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0);
|
||||
if (den === 0) return yMean;
|
||||
const slope = num / den;
|
||||
return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead)));
|
||||
}
|
||||
|
||||
const forecast90d = linearForecast(trend30, 90);
|
||||
const change7d = trend7.length >= 2
|
||||
? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
|
||||
: 0;
|
||||
const trendLabels = trend30.map(d => d.date);
|
||||
const trendValues = trend30.map(d => d.coverage_pct_atlas);
|
||||
const asnValues = trend30.map(d => d.aspa_objects);
|
||||
|
||||
const coverageNow = latest?.coverage_pct_atlas ?? 0;
|
||||
const aspaNow = latest?.aspa_objects ?? 0;
|
||||
const atlasTotal = latest?.atlas_asns_total ?? 0;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>ASPA Adoption Tracker · PeerCortex</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;background:#f5f3ff;color:#1e1b4b;min-height:100vh}
|
||||
nav{background:#4f46e5;color:#fff;padding:14px 24px;display:flex;align-items:center;gap:12px}
|
||||
nav .logo{font-size:16px;font-weight:700;letter-spacing:-0.5px}
|
||||
nav .logo span{opacity:0.7;font-weight:400}
|
||||
nav a{color:rgba(255,255,255,0.8);text-decoration:none;font-size:13px;margin-left:16px}
|
||||
nav a:hover{color:#fff}
|
||||
.container{max-width:1100px;margin:0 auto;padding:24px 20px}
|
||||
h1{font-size:22px;font-weight:700;color:#3730a3;margin-bottom:4px}
|
||||
.subtitle{font-size:13px;color:#6b7280;margin-bottom:24px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}
|
||||
.card{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
|
||||
.card .label{font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
|
||||
.card .value{font-size:28px;font-weight:700;color:#4f46e5;line-height:1}
|
||||
.card .unit{font-size:11px;color:#9ca3af;margin-top:4px}
|
||||
.card.green .value{color:#16a34a}
|
||||
.card.amber .value{color:#d97706}
|
||||
.chart-wrap{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:20px;margin-bottom:24px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
|
||||
.chart-wrap h2{font-size:14px;font-weight:600;color:#3730a3;margin-bottom:16px}
|
||||
.chart-row{display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:24px}
|
||||
.export-bar{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap}
|
||||
.btn{padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;border:none}
|
||||
.btn-primary{background:#4f46e5;color:#fff}
|
||||
.btn-outline{background:#fff;color:#4f46e5;border:1.5px solid #4f46e5}
|
||||
.btn:hover{opacity:0.9}
|
||||
.forecast-box{background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;border-radius:12px;padding:18px 22px}
|
||||
.forecast-box .label{font-size:10px;opacity:0.8;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
|
||||
.forecast-box .value{font-size:32px;font-weight:700;line-height:1}
|
||||
.forecast-box .unit{font-size:11px;opacity:0.7;margin-top:4px}
|
||||
.meta{font-size:11px;color:#9ca3af;margin-top:24px;text-align:center}
|
||||
@media(max-width:700px){.chart-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<span class="logo">⬡ PeerCortex <span>Network Intelligence</span></span>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/aspa-adoption">ASPA Tracker</a>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<h1>ASPA Adoption Tracker</h1>
|
||||
<p class="subtitle">Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card ${coverageNow >= 15 ? 'green' : 'amber'}">
|
||||
<div class="label">Atlas Coverage</div>
|
||||
<div class="value">${coverageNow.toFixed(1)}%</div>
|
||||
<div class="unit">of Atlas-visible ASNs</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">ASPA Objects</div>
|
||||
<div class="value">${aspaNow.toLocaleString()}</div>
|
||||
<div class="unit">total in RPKI</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Atlas ASNs</div>
|
||||
<div class="value">${atlasTotal.toLocaleString()}</div>
|
||||
<div class="unit">sampled denominator</div>
|
||||
</div>
|
||||
<div class="card ${change7d > 0 ? 'green' : ''}">
|
||||
<div class="label">7-Day Delta</div>
|
||||
<div class="value">${change7d > 0 ? '+' : ''}${change7d}</div>
|
||||
<div class="unit">new ASPA objects</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Data Points</div>
|
||||
<div class="value">${aspaAdoptionDailyHistory.length}</div>
|
||||
<div class="unit">history snapshots</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-bar">
|
||||
<a class="btn btn-primary" href="/api/aspa-adoption-stats/export?format=json&period=30d">↓ JSON (30d)</a>
|
||||
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=30d">↓ CSV (30d)</a>
|
||||
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=1y">↓ CSV (1y)</a>
|
||||
<a class="btn btn-outline" href="/api/export/pdf?asn=0&format=report" style="display:none">PDF Report</a>
|
||||
</div>
|
||||
|
||||
<div class="chart-row">
|
||||
<div class="chart-wrap">
|
||||
<h2>Atlas Coverage % (last 30 days)</h2>
|
||||
<canvas id="coverageChart" height="120"></canvas>
|
||||
</div>
|
||||
<div class="forecast-box">
|
||||
<div class="label">90-Day Forecast</div>
|
||||
<div class="value">${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}</div>
|
||||
<div class="unit">linear projection from 30-day trend</div>
|
||||
<div style="margin-top:16px;font-size:11px;opacity:0.8">
|
||||
Based on ${trend30.length} data points.<br/>
|
||||
Current: ${coverageNow.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-wrap">
|
||||
<h2>Total ASPA Objects in RPKI (last 30 days)</h2>
|
||||
<canvas id="asnChart" height="80"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
Last snapshot: ${latest?.date ?? 'none'} · Denominator: ${atlasTotal.toLocaleString()} Atlas ASNs ·
|
||||
<a href="/api/aspa-adoption-stats?period=30d" style="color:#4f46e5">JSON API</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const labels = ${JSON.stringify(trendLabels)};
|
||||
const covData = ${JSON.stringify(trendValues)};
|
||||
const asnData = ${JSON.stringify(asnValues)};
|
||||
|
||||
const commonOpts = {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: '#f0eeff' }, ticks: { maxRotation: 45, font: { size: 10 } } },
|
||||
y: { grid: { color: '#f0eeff' }, ticks: { font: { size: 10 } }, beginAtZero: false }
|
||||
}
|
||||
};
|
||||
|
||||
new Chart(document.getElementById('coverageChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: covData,
|
||||
borderColor: '#4f46e5',
|
||||
backgroundColor: 'rgba(79,70,229,0.08)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: { ...commonOpts, scales: { ...commonOpts.scales, y: { ...commonOpts.scales.y, ticks: { callback: v => v + '%', font: { size: 10 } } } } }
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('asnChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: asnData,
|
||||
backgroundColor: 'rgba(79,70,229,0.6)',
|
||||
borderColor: '#4f46e5',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: commonOpts
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
aspaAdoptionDailyHistory,
|
||||
loadAspaAdoptionHistory,
|
||||
saveAspaAdoptionHistory,
|
||||
recordAspaAdoptionSnapshot,
|
||||
getAdoptionTrend,
|
||||
buildAspaAdoptionDashboard,
|
||||
};
|
||||
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,
|
||||
};
|
||||
77
server/atlas-probes.js
Normal file
77
server/atlas-probes.js
Normal file
@ -0,0 +1,77 @@
|
||||
const { fetchJSON } = require("./services/http-helpers");
|
||||
|
||||
// Atlas Probe Cache (for Lia's Atlas Paradise). Exposed as a mutable state
|
||||
// object (not a bare reassigned export) so every requirer always sees the
|
||||
// current value after fetchAllAtlasProbes() resolves -- a plain
|
||||
// `module.exports = { atlasProbeCache }` would only capture the value (null)
|
||||
// at require time, since CommonJS destructuring doesn't track reassignment.
|
||||
const atlasState = {
|
||||
cache: null,
|
||||
fetching: false,
|
||||
};
|
||||
|
||||
function fetchAllAtlasProbes() {
|
||||
if (atlasState.fetching) return Promise.resolve();
|
||||
atlasState.fetching = true;
|
||||
console.log("[ATLAS] Fetching all Atlas probes...");
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
var allAsns = new Set();
|
||||
var byCountry = {};
|
||||
var pageCount = 0;
|
||||
var maxPages = 40;
|
||||
|
||||
function fetchPage(pageUrl) {
|
||||
if (pageCount >= maxPages) return finish();
|
||||
pageCount++;
|
||||
|
||||
fetchJSON(pageUrl).then(function(data) {
|
||||
if (!data || !data.results) return finish();
|
||||
|
||||
data.results.forEach(function(probe) {
|
||||
var asn4 = probe.asn_v4;
|
||||
var asn6 = probe.asn_v6;
|
||||
var cc = probe.country_code || "XX";
|
||||
|
||||
if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
|
||||
byCountry[cc].total++;
|
||||
if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
|
||||
if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
|
||||
if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
|
||||
});
|
||||
|
||||
if (data.next) {
|
||||
fetchPage(data.next);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
}).catch(function() { finish(); });
|
||||
}
|
||||
|
||||
function finish() {
|
||||
var byCountryOut = {};
|
||||
Object.keys(byCountry).forEach(function(cc) {
|
||||
var info = byCountry[cc];
|
||||
byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
|
||||
});
|
||||
|
||||
atlasState.cache = {
|
||||
total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
|
||||
total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
|
||||
unique_asns_with_probes: allAsns.size,
|
||||
asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
|
||||
by_country: byCountryOut,
|
||||
fetched_at: new Date().toISOString(),
|
||||
pages_fetched: pageCount,
|
||||
};
|
||||
|
||||
console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
|
||||
atlasState.fetching = false;
|
||||
resolve();
|
||||
}
|
||||
|
||||
fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { atlasState, fetchAllAtlasProbes };
|
||||
53
server/bgp-he-net.js
Normal file
53
server/bgp-he-net.js
Normal file
@ -0,0 +1,53 @@
|
||||
const { fetchHTML } = require("./services/http-helpers");
|
||||
|
||||
// Feature 24: bgp.he.net Integration.
|
||||
// NOTE: currently dead code in practice -- /api/lookup's only call site
|
||||
// (`timedFetch("bgp.he.net", ...)`) is hardcoded to `Promise.resolve(null)`
|
||||
// rather than actually invoking this. Moved as-is (not removed) since that's
|
||||
// a "disabled pending re-enable" state, not a provably-unreachable one --
|
||||
// removing a whole scraping feature is a bigger decision than this
|
||||
// behavior-preserving extraction pass should make unilaterally.
|
||||
async function fetchBgpHeNet(asn) {
|
||||
try {
|
||||
const html = await fetchHTML("https://bgp.he.net/AS" + asn);
|
||||
if (!html) return null;
|
||||
const result = {};
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||
if (titleMatch) result.title = titleMatch[1].trim();
|
||||
const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
|
||||
if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, ''));
|
||||
const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i);
|
||||
if (countryMatch) result.country = countryMatch[1].trim();
|
||||
// Extract 2-letter country code from href="/country/XX"
|
||||
const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i);
|
||||
if (ccMatch) result.country_code = ccMatch[1].toUpperCase();
|
||||
// Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name"
|
||||
if (titleMatch) {
|
||||
const rawTitle = titleMatch[1].trim();
|
||||
const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim();
|
||||
if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) {
|
||||
result.name_from_title = nameFromTitle;
|
||||
}
|
||||
}
|
||||
|
||||
const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i);
|
||||
if (lgMatch) result.looking_glass = lgMatch[1];
|
||||
const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
|
||||
if (descMatch) result.description = descMatch[1].trim();
|
||||
const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
|
||||
if (irrMatch) result.irr_record = irrMatch[1].trim();
|
||||
// bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147"
|
||||
const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
|
||||
if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
|
||||
const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
|
||||
if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
|
||||
const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
|
||||
if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
|
||||
result.source_url = "https://bgp.he.net/AS" + asn;
|
||||
return result;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchBgpHeNet };
|
||||
85
server/caches/pdb-source-cache.js
Normal file
85
server/caches/pdb-source-cache.js
Normal file
@ -0,0 +1,85 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
|
||||
// Eliminates redundant PDB API calls under load
|
||||
const pdbSourceCache = {
|
||||
net: new Map(), // key: asn string → {data, ts}
|
||||
netixlan: new Map(), // key: net_id string → {data, ts}
|
||||
netfac: new Map(), // key: net_id string → {data, ts}
|
||||
facCoords: new Map(), // key: fac_id string → {lat, lon, ts}
|
||||
TTL_NET: 6 * 60 * 60 * 1000, // 6 hours
|
||||
TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours
|
||||
TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
MAX_NET: 5000,
|
||||
MAX_IXFAC: 5000,
|
||||
MAX_COORDS: 10000,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
|
||||
get(type, key) {
|
||||
const map = this[type];
|
||||
const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC);
|
||||
const entry = map.get(String(key));
|
||||
if (!entry) { this.misses++; return null; }
|
||||
if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; }
|
||||
this.hits++;
|
||||
return entry.data;
|
||||
},
|
||||
|
||||
set(type, key, data) {
|
||||
const map = this[type];
|
||||
const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC);
|
||||
if (map.size >= max) {
|
||||
// Evict oldest entry (Map preserves insertion order)
|
||||
map.delete(map.keys().next().value);
|
||||
}
|
||||
map.set(String(key), { data, ts: Date.now() });
|
||||
},
|
||||
|
||||
// Disk persistence
|
||||
saveToDisk(filePath) {
|
||||
try {
|
||||
const serialize = (map) => {
|
||||
const obj = {};
|
||||
for (const [k, v] of map) obj[k] = v;
|
||||
return obj;
|
||||
};
|
||||
const data = JSON.stringify({
|
||||
ts: Date.now(),
|
||||
net: serialize(this.net),
|
||||
netixlan: serialize(this.netixlan),
|
||||
netfac: serialize(this.netfac),
|
||||
facCoords: serialize(this.facCoords),
|
||||
});
|
||||
fs.writeFileSync(filePath, data);
|
||||
console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
|
||||
} catch (e) {
|
||||
console.warn("[PDB-CACHE] Disk save failed:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
loadFromDisk(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
const now = Date.now();
|
||||
const load = (map, obj, ttl) => {
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
if (now - v.ts < ttl) map.set(k, v);
|
||||
}
|
||||
};
|
||||
load(this.net, data.net, this.TTL_NET);
|
||||
load(this.netixlan, data.netixlan, this.TTL_IXFAC);
|
||||
load(this.netfac, data.netfac, this.TTL_IXFAC);
|
||||
load(this.facCoords, data.facCoords, this.TTL_COORDS);
|
||||
console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[PDB-CACHE] Disk load failed:", e.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { pdbSourceCache };
|
||||
113
server/caches/response-caches.js
Normal file
113
server/caches/response-caches.js
Normal file
@ -0,0 +1,113 @@
|
||||
// Task 6: In-memory cache with TTL + Rate Limiting
|
||||
const responseCache = new Map();
|
||||
|
||||
function cacheGet(key) {
|
||||
const entry = responseCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expires) {
|
||||
responseCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function cacheSet(key, data, ttlMs) {
|
||||
responseCache.set(key, { data, expires: Date.now() + ttlMs });
|
||||
// Evict old entries periodically (keep cache under 500 entries)
|
||||
if (responseCache.size > 500) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of responseCache) {
|
||||
if (now > v.expires) responseCache.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_DEFAULT = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN
|
||||
const rdapCache = new Map(); // key: asn string, value: { data, ts }
|
||||
const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function rdapCacheGet(asn) {
|
||||
const e = rdapCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data;
|
||||
return undefined; // undefined = not cached, null = cached miss
|
||||
}
|
||||
function rdapCacheSet(asn, data) {
|
||||
if (rdapCache.size > 5000) {
|
||||
rdapCache.delete(rdapCache.keys().next().value);
|
||||
}
|
||||
rdapCache.set(String(asn), { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering
|
||||
const whoisCache = new Map(); // key: asn string, value: { data, ts, ttl }
|
||||
const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
// A "not found" result after all 5 RIR sources failed is far more often a shared
|
||||
// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real
|
||||
// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't
|
||||
// tell timeout/network-error apart from a genuine 404, so cache that specific
|
||||
// conclusion much more briefly than a confirmed, parsed WHOIS record.
|
||||
const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000;
|
||||
function whoisCacheGet(asn) {
|
||||
const e = whoisCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function whoisCacheSet(asn, data, ttl) {
|
||||
if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
|
||||
whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL });
|
||||
}
|
||||
|
||||
// Quick-IX Cache — 1h TTL, for Peering Recommendations
|
||||
const quickIxCache = new Map(); // key: asn string, value: { data, ts }
|
||||
const QUICK_IX_CACHE_TTL = 60 * 60 * 1000;
|
||||
function quickIxCacheGet(asn) {
|
||||
const e = quickIxCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function quickIxCacheSet(asn, data) {
|
||||
if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value);
|
||||
quickIxCache.set(String(asn), { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs
|
||||
const bgproutesResultCache = new Map();
|
||||
const aspaResultCache = new Map();
|
||||
const validateResultCache = new Map();
|
||||
const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
|
||||
function resultCacheGet(map, key) {
|
||||
const e = map.get(String(key));
|
||||
const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL;
|
||||
if (e && (Date.now() - e.ts) < ttl) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function resultCacheSet(map, key, data, ttlMs) {
|
||||
if (map.size > 2000) map.delete(map.keys().next().value);
|
||||
map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
responseCache,
|
||||
cacheGet,
|
||||
cacheSet,
|
||||
RESULT_CACHE_TTL,
|
||||
CACHE_TTL_LOOKUP,
|
||||
CACHE_TTL_LOOKUP_DEGRADED,
|
||||
CACHE_TTL_DEFAULT,
|
||||
rdapCacheGet,
|
||||
rdapCacheSet,
|
||||
whoisCacheGet,
|
||||
whoisCacheSet,
|
||||
WHOIS_NOT_FOUND_CACHE_TTL,
|
||||
quickIxCacheGet,
|
||||
quickIxCacheSet,
|
||||
bgproutesResultCache,
|
||||
aspaResultCache,
|
||||
validateResultCache,
|
||||
resultCacheGet,
|
||||
resultCacheSet,
|
||||
};
|
||||
225
server/caches/roa-store.js
Normal file
225
server/caches/roa-store.js
Normal file
@ -0,0 +1,225 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// Local ROA Store — validates prefixes without RIPE Stat API calls.
|
||||
// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA.
|
||||
// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix)
|
||||
const roaStore = {
|
||||
v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start
|
||||
v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex
|
||||
ready: false,
|
||||
count: 0,
|
||||
lastBuild: 0,
|
||||
|
||||
// Parse IPv4 prefix string to 32-bit unsigned integer
|
||||
_ipv4ToUint32(ip) {
|
||||
const parts = ip.split(".");
|
||||
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
},
|
||||
|
||||
// Build ROA store from Cloudflare feed roas array
|
||||
build(roas) {
|
||||
const v4 = [];
|
||||
const v6 = [];
|
||||
for (let i = 0; i < roas.length; i++) {
|
||||
const r = roas[i];
|
||||
const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn);
|
||||
const prefix = r.prefix;
|
||||
const maxLen = r.maxLength || r.maxPrefixLength || 0;
|
||||
if (!prefix || !asn) continue;
|
||||
|
||||
const slashIdx = prefix.indexOf("/");
|
||||
if (slashIdx < 0) continue;
|
||||
const prefixLen = parseInt(prefix.substring(slashIdx + 1));
|
||||
const addr = prefix.substring(0, slashIdx);
|
||||
|
||||
if (prefix.indexOf(":") >= 0) {
|
||||
// IPv6 — store as zero-padded hex string for sorting
|
||||
const expanded = this._expandIPv6(addr);
|
||||
if (expanded) {
|
||||
v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen });
|
||||
}
|
||||
} else {
|
||||
// IPv4 — store as numeric range
|
||||
const start = this._ipv4ToUint32(addr);
|
||||
const hostBits = 32 - prefixLen;
|
||||
const end = (start | ((1 << hostBits) - 1)) >>> 0;
|
||||
v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for binary search
|
||||
v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen);
|
||||
v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen);
|
||||
|
||||
this.v4Entries = v4;
|
||||
this.v6Entries = v6;
|
||||
this.count = v4.length + v6.length;
|
||||
this.ready = true;
|
||||
this.lastBuild = Date.now();
|
||||
},
|
||||
|
||||
// Expand IPv6 address to 32-char hex for reliable sorting
|
||||
_expandIPv6(addr) {
|
||||
try {
|
||||
let groups = addr.split("::");
|
||||
let left = groups[0] ? groups[0].split(":") : [];
|
||||
let right = groups.length > 1 && groups[1] ? groups[1].split(":") : [];
|
||||
const missing = 8 - left.length - right.length;
|
||||
const mid = [];
|
||||
for (let i = 0; i < missing; i++) mid.push("0000");
|
||||
const all = [...left, ...mid, ...right];
|
||||
return all.map(g => g.padStart(4, "0")).join("");
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Validate a prefix against the local ROA store
|
||||
// Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N}
|
||||
validate(asn, prefix) {
|
||||
if (!this.ready) return null; // Signal caller to use fallback
|
||||
|
||||
const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn);
|
||||
const slashIdx = prefix.indexOf("/");
|
||||
if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 };
|
||||
const prefixLen = parseInt(prefix.substring(slashIdx + 1));
|
||||
const addr = prefix.substring(0, slashIdx);
|
||||
|
||||
if (prefix.indexOf(":") >= 0) {
|
||||
return this._validateV6(asnNum, addr, prefixLen, prefix);
|
||||
}
|
||||
return this._validateV4(asnNum, addr, prefixLen, prefix);
|
||||
},
|
||||
|
||||
_validateV4(asn, addr, prefixLen, prefix) {
|
||||
const queryStart = this._ipv4ToUint32(addr);
|
||||
const entries = this.v4Entries;
|
||||
|
||||
// Binary search: find rightmost entry where start <= queryStart
|
||||
let lo = 0, hi = entries.length - 1;
|
||||
let insertionPoint = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (entries[mid].start <= queryStart) {
|
||||
insertionPoint = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from insertion point to find covering ROAs
|
||||
// ROAs are sorted by start, so we scan back while start could still cover our prefix
|
||||
const matched = [];
|
||||
const unmatchedAs = [];
|
||||
for (let i = insertionPoint; i >= 0; i--) {
|
||||
const e = entries[i];
|
||||
// If this ROA's network start is too far left, no more matches possible
|
||||
if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away
|
||||
// Check if query prefix is contained within this ROA
|
||||
if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) {
|
||||
if (prefixLen <= e.maxLen) {
|
||||
if (e.asn === asn) {
|
||||
matched.push(e);
|
||||
} else {
|
||||
unmatchedAs.push(e);
|
||||
}
|
||||
}
|
||||
// prefixLen > maxLen → too specific, invalid if ASN matches
|
||||
else if (e.asn === asn) {
|
||||
unmatchedAs.push(e); // ASN matches but length exceeds maxLen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
|
||||
if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
},
|
||||
|
||||
_validateV6(asn, addr, prefixLen, prefix) {
|
||||
const queryHex = this._expandIPv6(addr);
|
||||
if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 };
|
||||
const entries = this.v6Entries;
|
||||
|
||||
// Binary search for approximate position
|
||||
let lo = 0, hi = entries.length - 1;
|
||||
let insertionPoint = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (entries[mid].prefixHex <= queryHex) {
|
||||
insertionPoint = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const unmatchedAs = [];
|
||||
// Scan backwards from insertion point
|
||||
for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) {
|
||||
const e = entries[i];
|
||||
// Check if query prefix is covered by this ROA entry
|
||||
// A covering ROA has a shorter or equal prefix length and its network prefix matches
|
||||
if (e.prefixLen <= prefixLen) {
|
||||
// Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up)
|
||||
const hexChars = Math.ceil(e.prefixLen / 4);
|
||||
if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) {
|
||||
if (prefixLen <= e.maxLen) {
|
||||
if (e.asn === asn) matched.push(e);
|
||||
else unmatchedAs.push(e);
|
||||
} else if (e.asn === asn) {
|
||||
unmatchedAs.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop if we're too far away
|
||||
if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break;
|
||||
}
|
||||
|
||||
if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
|
||||
if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
},
|
||||
|
||||
// Persist to disk for fast restart
|
||||
saveToDisk(filePath) {
|
||||
try {
|
||||
const data = JSON.stringify({
|
||||
ts: this.lastBuild,
|
||||
v4Count: this.v4Entries.length,
|
||||
v6Count: this.v6Entries.length,
|
||||
v4: this.v4Entries,
|
||||
v6: this.v6Entries,
|
||||
});
|
||||
fs.writeFileSync(filePath, data);
|
||||
console.log("[ROA] Saved " + this.count + " ROAs to disk");
|
||||
} catch (e) {
|
||||
console.warn("[ROA] Disk save failed:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Load from disk cache (returns true if loaded)
|
||||
loadFromDisk(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
// Only use if less than 6 hours old
|
||||
if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false;
|
||||
this.v4Entries = data.v4;
|
||||
this.v6Entries = data.v6;
|
||||
this.count = data.v4Count + data.v6Count;
|
||||
this.lastBuild = data.ts;
|
||||
this.ready = true;
|
||||
console.log("[ROA] Loaded " + this.count + " ROAs from disk cache");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[ROA] Disk load failed:", e.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { roaStore };
|
||||
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 };
|
||||
72
server/hijack-monitoring/monitor.js
Normal file
72
server/hijack-monitoring/monitor.js
Normal file
@ -0,0 +1,72 @@
|
||||
const crypto = require("crypto");
|
||||
const localDb = require("../../local-db-client");
|
||||
const { loadHijackSubs, loadHijackAlerts, saveHijackAlerts, saveHijackSubs } = require("./store");
|
||||
const { notifyWebhooks } = require("./webhooks");
|
||||
|
||||
// Classify hijack severity
|
||||
function classifyHijackSeverity(unexpected, missing, rpkiInvalid) {
|
||||
if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL';
|
||||
if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH';
|
||||
if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM';
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
// Returns null (not []) when the prefix lookup itself failed -- distinct from a
|
||||
// genuine zero-prefix result. runHijackCheck must not treat these the same way:
|
||||
// setting an empty baseline from a transient failure would permanently disable
|
||||
// hijack detection for that subscription (baseline.size stays 0 forever after,
|
||||
// so "unexpected" never fires again -- see the baseline.size > 0 guard below).
|
||||
async function checkHijacksForAsn(asn) {
|
||||
try {
|
||||
const data = await localDb.getRipeStatAnnouncedPrefixes(asn);
|
||||
if (data && data.status === 'error') return null;
|
||||
const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
|
||||
return prefixes;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
async function runHijackCheck() {
|
||||
const subs = loadHijackSubs();
|
||||
if (!subs.length) return;
|
||||
const alerts = loadHijackAlerts();
|
||||
let changed = false;
|
||||
for (const sub of subs) {
|
||||
const current = await checkHijacksForAsn(sub.asn);
|
||||
if (current === null) {
|
||||
// Lookup failed this cycle -- skip both anomaly detection and baseline
|
||||
// initialization; retry on the next 30-minute run rather than locking in
|
||||
// an empty baseline that would silently disable detection forever.
|
||||
continue;
|
||||
}
|
||||
const baseline = new Set(sub.prefixes || []);
|
||||
const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p));
|
||||
const missing = [...baseline].filter(p => !current.includes(p));
|
||||
if (unexpected.length || missing.length) {
|
||||
// Dedup: only alert if no alert in last 6 hours for this ASN
|
||||
const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
|
||||
const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo);
|
||||
if (!recentAlert) {
|
||||
const severity = classifyHijackSeverity(unexpected, missing, 0);
|
||||
const alert = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
asn: sub.asn, ts: new Date().toISOString(),
|
||||
severity, unexpected, missing, resolved: false,
|
||||
msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing`
|
||||
};
|
||||
alerts.push(alert);
|
||||
changed = true;
|
||||
notifyWebhooks(sub.asn, alert);
|
||||
}
|
||||
}
|
||||
// Set baseline on first monitoring
|
||||
if (!sub.prefixes || !sub.prefixes.length) {
|
||||
sub.prefixes = current;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); }
|
||||
}
|
||||
// Run hijack check every 30 minutes
|
||||
setInterval(runHijackCheck, 30 * 60 * 1000);
|
||||
|
||||
module.exports = { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck };
|
||||
31
server/hijack-monitoring/store.js
Normal file
31
server/hijack-monitoring/store.js
Normal file
@ -0,0 +1,31 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Repo root (where server.js itself lives) -- NOT this module's own directory.
|
||||
// Preserves the original server.js `__dirname` fallback behavior (only ever
|
||||
// exercised locally; Erik always has /opt/peercortex-app and takes that branch).
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..");
|
||||
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : REPO_ROOT);
|
||||
const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
|
||||
const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
|
||||
const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
|
||||
|
||||
function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
|
||||
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }
|
||||
function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
|
||||
function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
|
||||
function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} }
|
||||
function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
|
||||
|
||||
module.exports = {
|
||||
DATA_DIR,
|
||||
HIJACK_SUBS_FILE,
|
||||
HIJACK_ALERTS_FILE,
|
||||
WEBHOOK_SUBS_FILE,
|
||||
loadHijackSubs,
|
||||
loadHijackAlerts,
|
||||
loadWebhookSubs,
|
||||
saveWebhookSubs,
|
||||
saveHijackAlerts,
|
||||
saveHijackSubs,
|
||||
};
|
||||
82
server/hijack-monitoring/webhooks.js
Normal file
82
server/hijack-monitoring/webhooks.js
Normal file
@ -0,0 +1,82 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const crypto = require("crypto");
|
||||
const { loadWebhookSubs } = require("./store");
|
||||
|
||||
// Generate HMAC-SHA256 signature for webhook payloads
|
||||
function webhookSignature(secret, payload) {
|
||||
return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
// Deliver webhook with exponential backoff (max 5 retries)
|
||||
async function deliverWebhook(sub, payload, attempt) {
|
||||
attempt = attempt || 1;
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const sig = webhookSignature(sub.secret, payloadStr);
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const parsed = new URL(sub.endpoint_url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payloadStr),
|
||||
'X-PeerCortex-Signature': sig,
|
||||
'X-PeerCortex-Event': 'hijack_detected',
|
||||
'User-Agent': 'PeerCortex-Webhook/1.0'
|
||||
},
|
||||
timeout: 10000
|
||||
};
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ ok: true, status: res.statusCode });
|
||||
} else if (attempt < 5) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
|
||||
} else {
|
||||
resolve({ ok: false, status: res.statusCode, error: 'max_retries' });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', () => {
|
||||
if (attempt < 5) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
|
||||
} else {
|
||||
resolve({ ok: false, error: 'connection_failed' });
|
||||
}
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); });
|
||||
req.write(payloadStr);
|
||||
req.end();
|
||||
} catch(e) {
|
||||
resolve({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fire webhooks for all subscribers of an ASN when hijack detected
|
||||
async function notifyWebhooks(asn, alert) {
|
||||
const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false);
|
||||
for (const sub of subs) {
|
||||
const payload = {
|
||||
event: 'hijack_detected',
|
||||
asn: asn,
|
||||
alert: alert,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'PeerCortex BGP Monitor'
|
||||
};
|
||||
deliverWebhook(sub, payload, 1).then(result => {
|
||||
if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { webhookSignature, deliverWebhook, notifyWebhooks };
|
||||
80
server/ipv6-adoption.js
Normal file
80
server/ipv6-adoption.js
Normal file
@ -0,0 +1,80 @@
|
||||
const { RIR_DELEGATION_URLS } = require("./data/rir-delegation-urls");
|
||||
|
||||
// FEATURE 4: IPv6 Adoption per RIR
|
||||
|
||||
// In-memory cache: { data, ts }
|
||||
let ipv6StatsCache = null;
|
||||
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
|
||||
|
||||
// Fetch and parse one RIR delegation file.
|
||||
// Returns { ipv4, ipv6, asns } count objects.
|
||||
function fetchRirDelegation(rir, url) {
|
||||
return new Promise((resolve) => {
|
||||
const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
|
||||
const mod = url.startsWith('https') ? require('https') : require('http');
|
||||
const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
|
||||
if (res.statusCode !== 200) { resolve(result); return; }
|
||||
let buf = '';
|
||||
res.on('data', d => { buf += d; });
|
||||
res.on('end', () => {
|
||||
for (const line of buf.split('\n')) {
|
||||
if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
|
||||
const parts = line.split('|');
|
||||
if (parts.length < 7) continue;
|
||||
const type = parts[2];
|
||||
const status = parts[6].trim();
|
||||
const cc = parts[1];
|
||||
if (status !== 'allocated' && status !== 'assigned') continue;
|
||||
if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
|
||||
else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
|
||||
else if (type === 'asn') { result.asns++; }
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve(result));
|
||||
req.on('timeout', () => { req.destroy(); resolve(result); });
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all 5 RIR delegation files and aggregate IPv6 stats.
|
||||
// Cached for 24 hours.
|
||||
async function fetchIpv6AdoptionStats(force) {
|
||||
if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
|
||||
return ipv6StatsCache.data;
|
||||
}
|
||||
console.log('[IPv6] Fetching RIR delegation stats...');
|
||||
const results = await Promise.all(
|
||||
Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
|
||||
);
|
||||
const rirs = results.map(r => ({
|
||||
rir: r.rir,
|
||||
ipv4_records: r.ipv4,
|
||||
ipv6_records: r.ipv6,
|
||||
asn_records: r.asns,
|
||||
ipv6_pct: (r.ipv4 + r.ipv6) > 0
|
||||
? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
|
||||
: 0,
|
||||
countries_with_ipv6: r.ipv6_countries.size,
|
||||
countries_with_ipv4: r.ipv4_countries.size,
|
||||
}));
|
||||
const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
|
||||
const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
|
||||
const data = {
|
||||
fetched_at: new Date().toISOString(),
|
||||
global_ipv6_pct: total_ipv4 + total_ipv6 > 0
|
||||
? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
|
||||
: 0,
|
||||
total_ipv4_records: total_ipv4,
|
||||
total_ipv6_records: total_ipv6,
|
||||
by_rir: rirs,
|
||||
};
|
||||
ipv6StatsCache = { data, ts: Date.now() };
|
||||
console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Pre-fetch on startup (non-blocking)
|
||||
fetchIpv6AdoptionStats().catch(() => {});
|
||||
|
||||
module.exports = { fetchRirDelegation, fetchIpv6AdoptionStats };
|
||||
92
server/pdb-org-countries.js
Normal file
92
server/pdb-org-countries.js
Normal file
@ -0,0 +1,92 @@
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { UA } = require("./data/constants");
|
||||
const { PEERINGDB_API_KEY } = require("./services/peeringdb");
|
||||
|
||||
// Repo root -- NOT this module's own directory. See hijack-monitoring/store.js
|
||||
// for the same __dirname-fallback fix applied for the same reason.
|
||||
const REPO_ROOT = path.join(__dirname, "..");
|
||||
|
||||
// PeeringDB Org → Country Cache (for Lia's Paradise). Exposed via a mutable
|
||||
// state object (not a bare reassigned export) for the same reason as
|
||||
// atlas-probes.js's atlasState -- the Map itself is reassigned wholesale on
|
||||
// refresh, not just mutated in place.
|
||||
const pdbOrgState = { map: new Map() }; // org_id → { country, name }
|
||||
|
||||
function fetchPdbOrgCountries() {
|
||||
var cacheFile = path.join(REPO_ROOT, ".pdb-org-cache.json");
|
||||
|
||||
// Try disk cache first (valid for 24h)
|
||||
try {
|
||||
var stat = fs.statSync(cacheFile);
|
||||
var ageHours = (Date.now() - stat.mtimeMs) / 3600000;
|
||||
if (ageHours < 24) {
|
||||
var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
||||
pdbOrgState.map = new Map(Object.entries(cached));
|
||||
console.log("[PDB-ORG] Loaded " + pdbOrgState.map.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)");
|
||||
return Promise.resolve();
|
||||
}
|
||||
} catch (_) { /* no cache or invalid */ }
|
||||
|
||||
console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)...");
|
||||
return new Promise(function(resolve) {
|
||||
var chunks = [];
|
||||
var req = https.get("https://www.peeringdb.com/api/org?status=ok&depth=0", {
|
||||
headers: {
|
||||
"User-Agent": UA,
|
||||
// Bug fix 2026-07-16: a bare `PEERINGDB_API_KEY ? ... : undefined`
|
||||
// sets the Authorization header value to `undefined` when no key is
|
||||
// configured. Node 22's stricter header validation throws
|
||||
// synchronously on that (ERR_HTTP_INVALID_HEADER_VALUE) instead of
|
||||
// omitting the header, crashing the whole process at startup via
|
||||
// this function's unhandled rejection (found empirically while
|
||||
// trying to boot server.js locally without a real PeeringDB key).
|
||||
// Conditionally spread the key in instead so the header key itself
|
||||
// is simply absent when unconfigured.
|
||||
...(PEERINGDB_API_KEY ? { "Authorization": "Api-Key " + PEERINGDB_API_KEY } : {}),
|
||||
},
|
||||
timeout: 120000,
|
||||
}, function(res) {
|
||||
if (res.statusCode !== 200) {
|
||||
console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
res.on("data", function(chunk) { chunks.push(chunk); });
|
||||
res.on("end", function() {
|
||||
try {
|
||||
var body = Buffer.concat(chunks).toString("utf8");
|
||||
var data = JSON.parse(body);
|
||||
if (data && data.data) {
|
||||
pdbOrgState.map = new Map();
|
||||
var cacheObj = {};
|
||||
data.data.forEach(function(o) {
|
||||
if (o.id && o.country) {
|
||||
pdbOrgState.map.set(o.id, { country: o.country, name: o.name || "" });
|
||||
cacheObj[o.id] = { country: o.country, name: o.name || "" };
|
||||
}
|
||||
});
|
||||
// Save to disk cache
|
||||
try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {}
|
||||
console.log("[PDB-ORG] Loaded " + pdbOrgState.map.size + " org→country mappings (cached to disk)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[PDB-ORG] Parse error:", e.message);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on("error", function(e) {
|
||||
console.error("[PDB-ORG] Fetch error:", e.message);
|
||||
resolve();
|
||||
});
|
||||
req.on("timeout", function() {
|
||||
console.error("[PDB-ORG] Timeout after 120s");
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { pdbOrgState, fetchPdbOrgCountries };
|
||||
65
server/pdf-export/renderer.js
Normal file
65
server/pdf-export/renderer.js
Normal file
@ -0,0 +1,65 @@
|
||||
// PDF Export: lazy puppeteer loader + response cache + renderer
|
||||
|
||||
// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
|
||||
let _puppeteerBrowser = null;
|
||||
let _puppeteerLoading = false;
|
||||
async function getPuppeteerBrowser() {
|
||||
if (_puppeteerBrowser) return _puppeteerBrowser;
|
||||
if (_puppeteerLoading) {
|
||||
// Wait for in-flight launch
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
return _puppeteerBrowser;
|
||||
}
|
||||
_puppeteerLoading = true;
|
||||
try {
|
||||
const puppeteer = require("puppeteer");
|
||||
_puppeteerBrowser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
||||
});
|
||||
console.log("[PDF] Puppeteer browser launched");
|
||||
_puppeteerBrowser.on("disconnected", () => {
|
||||
console.log("[PDF] Browser disconnected — will relaunch on next request");
|
||||
_puppeteerBrowser = null;
|
||||
_puppeteerLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PDF] Puppeteer unavailable:", e.message);
|
||||
_puppeteerBrowser = null;
|
||||
}
|
||||
_puppeteerLoading = false;
|
||||
return _puppeteerBrowser;
|
||||
}
|
||||
|
||||
// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
|
||||
const pdfCache = new Map();
|
||||
const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function pdfCacheGet(key) {
|
||||
const e = pdfCache.get(key);
|
||||
if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
|
||||
return null;
|
||||
}
|
||||
function pdfCacheSet(key, buf) {
|
||||
if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
|
||||
pdfCache.set(key, { buf, ts: Date.now() });
|
||||
}
|
||||
|
||||
async function renderHtmlToPdf(html) {
|
||||
const browser = await getPuppeteerBrowser();
|
||||
if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const buf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
|
||||
});
|
||||
return buf;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf };
|
||||
661
server/pdf-export/templates.js
Normal file
661
server/pdf-export/templates.js
Normal file
@ -0,0 +1,661 @@
|
||||
// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O
|
||||
|
||||
// FEATURE 2: PDF Export — template generators + puppeteer renderer
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Generate a health score ring SVG (inline, no external deps)
|
||||
* @param {number} score 0–100
|
||||
* @param {string} color CSS color
|
||||
*/
|
||||
function scoreRingSvg(score, color) {
|
||||
const r = 54, cx = 64, cy = 64;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const fill = circ * (1 - score / 100);
|
||||
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
|
||||
return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
|
||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
|
||||
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
|
||||
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
|
||||
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
|
||||
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
|
||||
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
|
||||
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Shared CSS for all PDF templates */
|
||||
function pdfBaseCSS() {
|
||||
return `
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
.page { page-break-after: always; min-height: 247mm; padding: 0; }
|
||||
.page:last-child { page-break-after: avoid; }
|
||||
h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
|
||||
h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
|
||||
h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
|
||||
p { margin-bottom: 0.5em; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
|
||||
th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
|
||||
td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
|
||||
tr:nth-child(even) td { background: #f5f3ff; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
|
||||
.badge-green { background: #dcfce7; color: #166534; }
|
||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||
.badge-yellow{ background: #fef9c3; color: #854d0e; }
|
||||
.badge-gray { background: #f3f4f6; color: #374151; }
|
||||
.badge-indigo{ background: #e0e7ff; color: #3730a3; }
|
||||
.cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
|
||||
.cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
|
||||
.cover-logo span { color: #1e1b4b; }
|
||||
.cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
|
||||
.cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
|
||||
.cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
|
||||
.cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
|
||||
.cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
|
||||
.metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
|
||||
.metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
|
||||
.metric-card .unit { font-size: 8pt; color: #9ca3af; }
|
||||
.score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
|
||||
.check-list { list-style: none; padding: 0; }
|
||||
.check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
|
||||
.check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
|
||||
.check-pass { color: #16a34a; }
|
||||
.check-fail { color: #dc2626; }
|
||||
.check-warn { color: #d97706; }
|
||||
.section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
|
||||
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PDF HTML for a single ASN.
|
||||
* @param {object} data Full /api/validate response
|
||||
* @param {object} aspa /api/aspa response (may be null)
|
||||
* @param {string} format 'report' | 'executive' | 'technical'
|
||||
*/
|
||||
function buildPdfHtml(data, aspa, format) {
|
||||
const asn = data.asn || '?';
|
||||
const name = data.name || 'Unknown Network';
|
||||
const score = Math.round(data.health_score || 0);
|
||||
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const checks = (data.score_breakdown || []);
|
||||
const rel = data.relationships || {};
|
||||
const counts = rel.counts || {};
|
||||
// validations is an object keyed by check name; rpki details live in rpki_completeness
|
||||
const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
|
||||
const rpkiDetails = (validations.rpki_completeness?.details || []);
|
||||
const totalPfx = data.meta?.total_prefixes || 0;
|
||||
|
||||
// Derived stats — use rpki_completeness for RPKI metrics
|
||||
const rpki = validations.rpki_completeness || {};
|
||||
const rpkiCoverage = rpki.coverage_pct ?? 0;
|
||||
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
|
||||
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
|
||||
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
|
||||
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
|
||||
|
||||
const passedChecks = checks.filter(c => c.status === 'pass').length;
|
||||
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
||||
const aspaDirect = aspa?.direct?.has_aspa ?? null;
|
||||
const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
|
||||
|
||||
// Check list HTML
|
||||
function checkIcon(passed) {
|
||||
if (passed) return '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
const checksHtml = checks.map(c => {
|
||||
const isPassed = c.status === 'pass';
|
||||
const isInfo = c.status === 'info';
|
||||
const icon = checkIcon(isPassed || isInfo);
|
||||
const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
|
||||
const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
|
||||
return `
|
||||
<li>
|
||||
${icon}
|
||||
<div>
|
||||
<span style="font-weight:600">${escHtml(c.check || '')}</span>
|
||||
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
|
||||
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
|
||||
// Upstream providers table
|
||||
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
|
||||
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
|
||||
`).join('');
|
||||
|
||||
// Prefix validations table (sample from rpki_completeness.details)
|
||||
const pfxRows = rpkiDetails.slice(0, 20).map(v => {
|
||||
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
||||
return `<tr><td>${escHtml(v.prefix)}</td>
|
||||
<td><span class="badge ${badge}">${v.status}</span></td>
|
||||
<td>${v.validating_roas ?? '-'}</td>
|
||||
<td>—</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
// Recommendations
|
||||
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
|
||||
const recHtml = failedItems.length === 0
|
||||
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
|
||||
: failedItems.map((c, i) => `
|
||||
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
|
||||
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
|
||||
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
// ── Executive pages (always present) ───────────────────────────────────────
|
||||
const coverPage = `
|
||||
<div class="page cover">
|
||||
<div class="cover-stripe"></div>
|
||||
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
||||
<div class="cover-asn">AS${asn}</div>
|
||||
<div class="cover-name">${escHtml(name)}</div>
|
||||
<div class="cover-sub">${formatLabel(format)} Report</div>
|
||||
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
|
||||
${scoreRingSvg(score)}
|
||||
<div>
|
||||
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
|
||||
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
|
||||
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-meta">
|
||||
Generated by PeerCortex · ${ts} · peercortex.org
|
||||
| ${totalPfx} prefixes analysed
|
||||
| RPKI coverage ${rpkiCoverage}%
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const summaryPage = `
|
||||
<div class="page">
|
||||
<h2>Executive Summary</h2>
|
||||
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
|
||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
|
||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
|
||||
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
||||
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
|
||||
<div class="metric-card"><div class="label">ASPA Status</div>
|
||||
<div class="value" style="font-size:12pt;margin-top:4px">
|
||||
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
|
||||
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="unit">${aspaProviders} providers</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Status Overview</h3>
|
||||
<ul class="check-list">${checksHtml}</ul>
|
||||
</div>`;
|
||||
|
||||
// ── Recommendations page ────────────────────────────────────────────────────
|
||||
const recsPage = `
|
||||
<div class="page">
|
||||
<h2>Recommendations</h2>
|
||||
<p class="section-intro">Action items to improve network health and security posture.</p>
|
||||
${recHtml}
|
||||
${failedItems.length > 0 ? `
|
||||
<h3 style="margin-top:1.5em">Remediation Priority</h3>
|
||||
<table>
|
||||
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
|
||||
${failedItems.map((c, i) => `
|
||||
<tr>
|
||||
<td>${i+1}</td>
|
||||
<td>${escHtml(c.check || '')}</td>
|
||||
<td><span class="badge badge-red">High</span></td>
|
||||
<td><span class="badge badge-yellow">Medium</span></td>
|
||||
</tr>`).join('')}
|
||||
</table>` : ''}
|
||||
</div>`;
|
||||
|
||||
// ── Detail pages (included for 'report' and 'technical') ───────────────────
|
||||
const rpkiPage = `
|
||||
<div class="page">
|
||||
<h2>RPKI Compliance</h2>
|
||||
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
|
||||
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
|
||||
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
|
||||
</div>
|
||||
<h3>ASPA (Autonomous System Provider Authorization)</h3>
|
||||
<table>
|
||||
<tr><th>Attribute</th><th>Value</th></tr>
|
||||
<tr><td>ASPA Object Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
||||
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
||||
<tr><td>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
||||
</table>
|
||||
${pfxRows ? `
|
||||
<h3>Prefix Validation Sample (first 20)</h3>
|
||||
<table>
|
||||
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
|
||||
${pfxRows}
|
||||
</table>` : ''}
|
||||
</div>`;
|
||||
|
||||
const bgpPage = `
|
||||
<div class="page">
|
||||
<h2>BGP Topology</h2>
|
||||
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
||||
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
||||
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
|
||||
</div>
|
||||
${upstreamRows ? `
|
||||
<h3>Top Upstream Providers</h3>
|
||||
<table>
|
||||
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
||||
${upstreamRows}
|
||||
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
||||
</div>`;
|
||||
|
||||
const footer = `
|
||||
<footer>
|
||||
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
|
||||
<span>Generated ${ts} · peercortex.org</span>
|
||||
</footer>`;
|
||||
|
||||
// ── Extra pages for technical / report formats ─────────────────────────────
|
||||
|
||||
// Technical: full prefix table (no row cap) + all upstream rows + check scores
|
||||
const allPfxRows = rpkiDetails.map(v => {
|
||||
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
||||
return `<tr><td>${escHtml(v.prefix)}</td>
|
||||
<td><span class="badge ${badge}">${v.status}</span></td>
|
||||
<td>${v.validating_roas ?? '-'}</td>
|
||||
<td>${v.max_length ?? '-'}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
const allUpstreamRows = (rel.upstreams || []).map(u =>
|
||||
`<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>`
|
||||
).join('');
|
||||
|
||||
const checkScoreRows = checks.map(c =>
|
||||
`<tr>
|
||||
<td>${escHtml(c.check || '')}</td>
|
||||
<td><span class="badge ${c.status === 'pass' ? 'badge-green' : c.status === 'info' ? 'badge-indigo' : 'badge-red'}">${c.status?.toUpperCase()}</span></td>
|
||||
<td>${c.earned ?? '-'}</td><td>${c.weight ?? '-'}</td>
|
||||
<td style="font-size:8pt;color:#6b7280">${escHtml(c.detail || '')}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
|
||||
const technicalHeaderPage = `
|
||||
<div class="page" style="padding-top:2cm">
|
||||
<div style="border-left:4px solid #6366f1;padding-left:16px;margin-bottom:24px">
|
||||
<div style="font-size:8pt;text-transform:uppercase;letter-spacing:1px;color:#6b7280">PeerCortex · Technical Analysis Report</div>
|
||||
<div style="font-size:22pt;font-weight:700;margin:4px 0">AS${asn} — ${escHtml(name)}</div>
|
||||
<div style="font-size:9pt;color:#6b7280">Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%</div>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}/100</div><div class="unit">${passedChecks} pass · ${failedChecks} fail</div></div>
|
||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing</div></div>
|
||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
||||
<div class="metric-card"><div class="label">Peers / Downstreams</div><div class="value">${counts.peers || 0} / ${counts.downstreams || 0}</div><div class="unit">BGP relationships</div></div>
|
||||
<div class="metric-card"><div class="label">ASPA</div>
|
||||
<div class="value" style="font-size:11pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Deployed' : 'Not deployed'}</span></div>
|
||||
<div class="unit">${aspaProviders} providers registered</div>
|
||||
</div>
|
||||
<div class="metric-card"><div class="label">Prefixes Analysed</div><div class="value">${totalPfx}</div><div class="unit">${rpkiDetails.length} with RPKI data</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const rpkiFullPage = `
|
||||
<div class="page">
|
||||
<h2>RPKI Compliance — Full Prefix Table</h2>
|
||||
<p class="section-intro">${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}</p>
|
||||
<h3>ASPA Object</h3>
|
||||
<table>
|
||||
<tr><th>Attribute</th><th>Value</th></tr>
|
||||
<tr><td>ASPA Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
||||
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
||||
<tr><td>Registered Providers</td><td style="font-size:8pt">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
||||
</table>
|
||||
${allPfxRows ? `
|
||||
<h3 style="margin-top:1.5em">All Prefixes</h3>
|
||||
<table>
|
||||
<tr><th>Prefix</th><th>RPKI Status</th><th>Validating ROAs</th><th>Max Length</th></tr>
|
||||
${allPfxRows}
|
||||
</table>` : '<p style="color:#9ca3af">No RPKI prefix data available.</p>'}
|
||||
</div>`;
|
||||
|
||||
const bgpFullPage = `
|
||||
<div class="page">
|
||||
<h2>BGP Topology — Full Relationship Table</h2>
|
||||
<p class="section-intro">Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}</p>
|
||||
${allUpstreamRows ? `
|
||||
<h3>All Upstream Providers</h3>
|
||||
<table>
|
||||
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
||||
${allUpstreamRows}
|
||||
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
||||
${(rel.peers || []).length > 0 ? `
|
||||
<h3 style="margin-top:1.5em">Peer Sample (top 20)</h3>
|
||||
<table>
|
||||
<tr><th>ASN</th><th>Name</th></tr>
|
||||
${(rel.peers || []).slice(0, 20).map(p => `<tr><td>AS${p.asn}</td><td>${escHtml(p.name || '')}</td></tr>`).join('')}
|
||||
</table>` : ''}
|
||||
</div>`;
|
||||
|
||||
const checkDetailPage = `
|
||||
<div class="page">
|
||||
<h2>Check Score Detail</h2>
|
||||
<p class="section-intro">All ${checks.length} health checks with individual scores and diagnostic notes.</p>
|
||||
<table>
|
||||
<tr><th>Check</th><th>Status</th><th>Earned</th><th>Weight</th><th>Detail</th></tr>
|
||||
${checkScoreRows}
|
||||
</table>
|
||||
<div style="margin-top:1.5em;padding:12px;background:#f9fafb;border:1px solid #e5e7eb;font-size:8pt;color:#6b7280">
|
||||
<strong>Scoring:</strong> Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
|
||||
Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Report: add data provenance and ASPA deep-dive
|
||||
const aspaPage = `
|
||||
<div class="page">
|
||||
<h2>ASPA — Provider Authorization Analysis</h2>
|
||||
<p class="section-intro">Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">ASPA Deployed</div>
|
||||
<div class="value" style="font-size:12pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Yes' : 'No'}</span></div>
|
||||
</div>
|
||||
<div class="metric-card"><div class="label">Providers Listed</div><div class="value">${aspaProviders}</div><div class="unit">in ASPA object</div></div>
|
||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">of announced prefixes</div></div>
|
||||
</div>
|
||||
<h3>What ASPA Protects Against</h3>
|
||||
<table>
|
||||
<tr><th>Threat</th><th>ASPA Mitigates?</th><th>Notes</th></tr>
|
||||
<tr><td>Route leaks (valley-free violations)</td><td><span class="badge badge-green">Yes</span></td><td>Providers verify upstream path</td></tr>
|
||||
<tr><td>BGP hijacks (origin AS spoofing)</td><td><span class="badge badge-indigo">Partial</span></td><td>Combined with ROV</td></tr>
|
||||
<tr><td>AS-path prepending attacks</td><td><span class="badge badge-green">Yes</span></td><td>Invalid paths rejected</td></tr>
|
||||
<tr><td>Forged-origin hijacks</td><td><span class="badge badge-red">No</span></td><td>ROA + ROV required</td></tr>
|
||||
</table>
|
||||
${aspaDirect === false ? `
|
||||
<div style="margin-top:1.5em;padding:14px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
|
||||
<div style="font-weight:600;color:#991b1b;margin-bottom:6px">Recommendation: Deploy ASPA</div>
|
||||
<div style="font-size:9pt;color:#6b7280">
|
||||
1. Identify all upstream transit providers (${counts.upstreams || 0} detected)<br>
|
||||
2. Create an ASPA object in your RIR portal listing their ASNs<br>
|
||||
3. Sign with your RPKI key — takes effect within hours<br>
|
||||
4. Verify with: <code>rpki-client -v</code> or RIPE RPKI Validator
|
||||
</div>
|
||||
</div>` : `
|
||||
<div style="margin-top:1.5em;padding:14px;background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px">
|
||||
<div style="font-weight:600;color:#15803d">ASPA is deployed — listed providers:</div>
|
||||
<div style="font-size:9pt;color:#374151;margin-top:6px">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}</div>
|
||||
</div>`}
|
||||
</div>`;
|
||||
|
||||
const provenancePage = `
|
||||
<div class="page">
|
||||
<h2>Data Provenance</h2>
|
||||
<p class="section-intro">All data sources, cache ages, and methodology used to generate this report.</p>
|
||||
<table>
|
||||
<tr><th>Data Source</th><th>What it provides</th><th>Update Frequency</th></tr>
|
||||
<tr><td>Cloudflare RPKI Feed</td><td>ROA + ASPA objects</td><td>Every 4 hours</td></tr>
|
||||
<tr><td>PeeringDB</td><td>IXP presence, peering policy, facilities</td><td>Daily (03:00 UTC)</td></tr>
|
||||
<tr><td>RIPE Stat</td><td>AS relationships, prefix visibility, BGP data</td><td>Real-time / cached 15 min</td></tr>
|
||||
<tr><td>RIPE Atlas</td><td>Active measurement probes by ASN</td><td>Cached 12 hours</td></tr>
|
||||
<tr><td>bgproutes.io</td><td>BGP route visibility fallback</td><td>Real-time</td></tr>
|
||||
<tr><td>bgp.he.net</td><td>AS relationship graph</td><td>Cached 24 hours</td></tr>
|
||||
</table>
|
||||
<h3 style="margin-top:1.5em">Report Metadata</h3>
|
||||
<table>
|
||||
<tr><th>Field</th><th>Value</th></tr>
|
||||
<tr><td>ASN</td><td>AS${asn}</td></tr>
|
||||
<tr><td>Network Name</td><td>${escHtml(name)}</td></tr>
|
||||
<tr><td>Report Format</td><td>${formatLabel(format)}</td></tr>
|
||||
<tr><td>Generated</td><td>${ts}</td></tr>
|
||||
<tr><td>Platform</td><td>PeerCortex · peercortex.org</td></tr>
|
||||
<tr><td>Prefixes Analysed</td><td>${totalPfx}</td></tr>
|
||||
<tr><td>RPKI Objects Checked</td><td>${rpkiDetails.length}</td></tr>
|
||||
<tr><td>Health Checks Run</td><td>${checks.length}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:1.5em;font-size:8pt;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:12px">
|
||||
This report is generated automatically based on publicly available BGP and RPKI data.
|
||||
Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
|
||||
MIT License · github.com/renefichtmueller/PeerCortex
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── Compose pages by format ────────────────────────────────────────────────
|
||||
let bodyContent;
|
||||
if (format === 'executive') {
|
||||
// 3 pages: clean stakeholder summary — no deep technical data
|
||||
bodyContent = coverPage + summaryPage + recsPage;
|
||||
} else if (format === 'technical') {
|
||||
// ~8 pages: engineer-focused, dense, no decorative cover, full data tables
|
||||
bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
|
||||
} else {
|
||||
// 'report' — full stakeholder + technical, ~10 pages
|
||||
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>PeerCortex — AS${asn} ${formatLabel(format)}</title>
|
||||
<style>${pdfBaseCSS()}</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyContent}
|
||||
${footer}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build comparison PDF HTML for two ASNs.
|
||||
*/
|
||||
function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
|
||||
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
function col(d, aspa) {
|
||||
const asn = d.asn || '?';
|
||||
const name = d.name || 'Unknown';
|
||||
const score = Math.round(d.health_score || 0);
|
||||
const rel = d.relationships || {};
|
||||
const counts= rel.counts || {};
|
||||
const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
|
||||
const rpki = vals.rpki_completeness || {};
|
||||
const rpkiV = rpki.with_roa || 0;
|
||||
const rpkiT = rpki.total_checked || 0;
|
||||
const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
|
||||
const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
||||
const aspaDep = aspa?.direct?.has_aspa ?? null;
|
||||
const checks= (d.score_breakdown || []);
|
||||
const passC = checks.filter(c => c.status === 'pass').length;
|
||||
const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||
|
||||
return `
|
||||
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
|
||||
<div style="text-align:center;padding:16px 0 12px;">
|
||||
${scoreRingSvg(score, sc)}
|
||||
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
|
||||
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
|
||||
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
|
||||
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
|
||||
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
|
||||
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
|
||||
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
|
||||
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
|
||||
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
|
||||
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
|
||||
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
|
||||
</table>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
const colHtml = col(d1, aspa1) + col(d2, aspa2);
|
||||
|
||||
// ── Peering Intelligence: common IXPs + common facilities ─────────────────
|
||||
const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
|
||||
const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
|
||||
const ixIds1 = new Set(ixList1.map(x => x.id));
|
||||
const ixIds2 = new Set(ixList2.map(x => x.id));
|
||||
const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
|
||||
const commonIxps = commonIxIds.slice(0, 20).map(id => {
|
||||
const x1 = ixList1.find(x => x.id === id);
|
||||
const x2 = ixList2.find(x => x.id === id);
|
||||
return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
|
||||
});
|
||||
|
||||
const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
|
||||
const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
|
||||
const facIds1 = new Set(facList1.map(f => f.id));
|
||||
const facIds2 = new Set(facList2.map(f => f.id));
|
||||
const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
|
||||
const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
|
||||
|
||||
// IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
|
||||
const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
|
||||
const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
|
||||
|
||||
const commonIxHtml = commonIxps.length > 0
|
||||
? `<table>
|
||||
<tr><th>IXP Name</th><th>City</th><th>AS${d1.asn} Speed</th><th>AS${d2.asn} Speed</th></tr>
|
||||
${commonIxps.map(x => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${escHtml(x.name)}</td>
|
||||
<td>${escHtml(x.city)}</td>
|
||||
<td>${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}</td>
|
||||
<td>${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`
|
||||
: '<p style="color:#6b7280;font-style:italic">No common IXPs found — these networks do not share any Internet Exchange Points.</p>';
|
||||
|
||||
const commonFacHtml = commonFacs.length > 0
|
||||
? `<table>
|
||||
<tr><th>Datacenter / Colocation Facility</th><th>City</th><th>Country</th></tr>
|
||||
${commonFacs.map(f => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${escHtml(f?.name||'')}</td>
|
||||
<td>${escHtml(f?.city||'')}</td>
|
||||
<td>${escHtml(f?.country||'')}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`
|
||||
: '<p style="color:#6b7280;font-style:italic">No shared colocation facilities found.</p>';
|
||||
|
||||
const oppsHtml = (asns, opps) => opps.length === 0
|
||||
? `<p style="color:#16a34a;font-size:9pt">Already maximally peered on these exchanges — or no data available.</p>`
|
||||
: opps.map(x => `<div style="padding:4px 0;border-bottom:1px solid #f3f4f6;font-size:9pt">
|
||||
<span style="font-weight:600">${escHtml(x.name)}</span>
|
||||
${x.city ? `<span style="color:#6b7280"> · ${escHtml(x.city)}</span>` : ''}
|
||||
${x.speed ? `<span class="badge badge-indigo" style="margin-left:6px">${(x.speed/1000).toLocaleString()}G</span>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
|
||||
<div class="page">
|
||||
<h2>Peering Intelligence</h2>
|
||||
<p class="section-intro">Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.</p>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">
|
||||
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonIxps.length}</div>
|
||||
<div style="font-size:9pt;color:#6b7280">Common IXPs</div>
|
||||
</div>
|
||||
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonFacs.length}</div>
|
||||
<div style="font-size:9pt;color:#6b7280">Shared Facilities</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Common Internet Exchange Points (direct peering possible)</h3>
|
||||
${commonIxHtml}
|
||||
|
||||
<h3 style="margin-top:1.2em">Shared Colocation Facilities</h3>
|
||||
${commonFacHtml}
|
||||
|
||||
${oppsFor1.length + oppsFor2.length > 0 ? `
|
||||
<h3 style="margin-top:1.2em">Expansion Opportunities</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d2.asn} could join (AS${d1.asn} is there)</div>
|
||||
${oppsHtml(d2.asn, oppsFor2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d1.asn} could join (AS${d2.asn} is there)</div>
|
||||
${oppsHtml(d1.asn, oppsFor1)}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>` : '';
|
||||
|
||||
// Side-by-side check comparison
|
||||
const allChecks = [...new Set([
|
||||
...(d1.score_breakdown || []).map(c => c.check),
|
||||
...(d2.score_breakdown || []).map(c => c.check),
|
||||
])];
|
||||
const checkRows = allChecks.map(checkName => {
|
||||
const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
|
||||
const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
|
||||
const isP1 = c1?.status === 'pass';
|
||||
const isP2 = c2?.status === 'pass';
|
||||
const icon = p => p ? '✓' : '✗';
|
||||
const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
|
||||
return `<tr>
|
||||
<td>${escHtml(checkName)}</td>
|
||||
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
|
||||
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison</title>
|
||||
<style>${pdfBaseCSS()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page cover">
|
||||
<div class="cover-stripe"></div>
|
||||
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
||||
<div style="margin-top:40px">
|
||||
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
|
||||
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
|
||||
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}</div>
|
||||
</div>
|
||||
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
|
||||
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<h2>Head-to-Head Health Checks</h2>
|
||||
<table>
|
||||
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
|
||||
${checkRows}
|
||||
</table>
|
||||
</div>
|
||||
${peeringPage}
|
||||
<footer>
|
||||
<span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
|
||||
<span>Generated ${ts} · peercortex.org</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function formatLabel(fmt) {
|
||||
return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };
|
||||
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 };
|
||||
23
server/resolve-as-names.js
Normal file
23
server/resolve-as-names.js
Normal file
@ -0,0 +1,23 @@
|
||||
const { fetchRipeStatCached } = require("./services/ripe-stat");
|
||||
|
||||
// Batch resolve AS names via RIPE Stat AS overview API
|
||||
async function resolveASNames(providers) {
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < providers.length; i += batchSize) {
|
||||
const batch = providers.slice(i, i + batchSize);
|
||||
const results = await Promise.all(
|
||||
batch.map(p =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
|
||||
.then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
|
||||
.catch(() => ({ asn: p.asn, name: "" }))
|
||||
)
|
||||
);
|
||||
results.forEach(r => {
|
||||
const provider = providers.find(p => p.asn === r.asn);
|
||||
if (provider && r.name) provider.name = r.name;
|
||||
});
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
module.exports = { resolveASNames };
|
||||
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 };
|
||||
114
server/routes/aspa-adoption.js
Normal file
114
server/routes/aspa-adoption.js
Normal file
@ -0,0 +1,114 @@
|
||||
const { rpkiAspaMap } = require("../services/rpki");
|
||||
const { fetchIpv6AdoptionStats } = require("../ipv6-adoption");
|
||||
const {
|
||||
aspaAdoptionDailyHistory, getAdoptionTrend, buildAspaAdoptionDashboard,
|
||||
} = require("../aspa-adoption/tracker");
|
||||
|
||||
// GET /aspa-adoption → dashboard HTML
|
||||
function handleDashboard(req, res) {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.writeHead(200);
|
||||
return res.end(buildAspaAdoptionDashboard());
|
||||
}
|
||||
|
||||
// Linear regression forecast -- duplicated near-identically in
|
||||
// buildAspaAdoptionDashboard (server/aspa-adoption/tracker.js) since both
|
||||
// use the same formula over possibly-different trend windows; kept as two
|
||||
// call sites rather than merged to avoid changing either's behavior here.
|
||||
function forecast(data, daysAhead) {
|
||||
if (data.length < 2) return null;
|
||||
const n = data.length;
|
||||
const xs = data.map((_, i) => i);
|
||||
const ys = data.map(d => d.coverage_pct_atlas);
|
||||
const xMean = xs.reduce((a,b)=>a+b,0)/n;
|
||||
const yMean = ys.reduce((a,b)=>a+b,0)/n;
|
||||
const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
|
||||
const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
|
||||
if (den===0) return yMean;
|
||||
const slope = num/den;
|
||||
return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
|
||||
}
|
||||
|
||||
// GET /api/aspa-adoption-stats?period=7d|30d|1y
|
||||
function handleStats(req, res, url) {
|
||||
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
||||
? url.searchParams.get('period') : '30d';
|
||||
const trend = getAdoptionTrend(period);
|
||||
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
|
||||
|
||||
const trend30 = getAdoptionTrend('30d');
|
||||
const result = {
|
||||
period,
|
||||
current: {
|
||||
date: latest?.date,
|
||||
aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
|
||||
roa_count: latest?.roa_count,
|
||||
atlas_asns_total: latest?.atlas_asns_total ?? 0,
|
||||
atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
|
||||
coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
|
||||
delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
|
||||
},
|
||||
trend: trend.map(s => ({
|
||||
date: s.date,
|
||||
aspa_objects: s.aspa_objects,
|
||||
coverage_pct_atlas: s.coverage_pct_atlas,
|
||||
atlas_asns_total: s.atlas_asns_total,
|
||||
})),
|
||||
forecast: {
|
||||
predicted_90d: forecast(trend30, 90),
|
||||
confidence: trend30.length >= 10 ? 0.75 : 0.5,
|
||||
method: 'linear_regression',
|
||||
based_on_samples: trend30.length,
|
||||
},
|
||||
meta: {
|
||||
total_snapshots: aspaAdoptionDailyHistory.length,
|
||||
last_updated: latest?.date ?? null,
|
||||
denominator: 'atlas_visible_asns',
|
||||
source: 'rpki_cloudflare_feed',
|
||||
}
|
||||
};
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
|
||||
function handleStatsExport(req, res, url) {
|
||||
const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
|
||||
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
||||
? url.searchParams.get('period') : '30d';
|
||||
const data = getAdoptionTrend(period);
|
||||
|
||||
if (fmt === 'csv') {
|
||||
const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
|
||||
const rows = data.map(s =>
|
||||
`${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
|
||||
).join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
|
||||
res.writeHead(200);
|
||||
return res.end(header + rows);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
|
||||
}
|
||||
|
||||
// GET /api/ipv6-adoption-stats?refresh=1
|
||||
async function handleIpv6Stats(req, res, url) {
|
||||
const force = url.searchParams.get('refresh') === '1';
|
||||
try {
|
||||
const data = await fetchIpv6AdoptionStats(force);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleDashboard, handleStats, handleStatsExport, handleIpv6Stats };
|
||||
133
server/routes/aspa-legacy.js
Normal file
133
server/routes/aspa-legacy.js
Normal file
@ -0,0 +1,133 @@
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { resolveASNames } = require("../resolve-as-names");
|
||||
const { ensureAspaCache, lookupAspaFromRpki } = require("../services/rpki");
|
||||
const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
|
||||
async function handleAspaLegacy(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 cachedAspa = resultCacheGet(aspaResultCache, rawAsn);
|
||||
if (cachedAspa !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(cachedAspa));
|
||||
}
|
||||
const start = Date.now();
|
||||
let _aspaDone = false;
|
||||
const _aspaTimer = setTimeout(() => {
|
||||
if (!_aspaDone) {
|
||||
_aspaDone = true;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
|
||||
}
|
||||
}, 12000);
|
||||
try {
|
||||
const [lgData, neighbourData] = await Promise.all([
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }),
|
||||
]);
|
||||
|
||||
const rrcs = lgData?.data?.rrcs || [];
|
||||
const asPaths = [];
|
||||
|
||||
rrcs.forEach((rrc) => {
|
||||
const peers = rrc.peers || [];
|
||||
peers.forEach((peer) => {
|
||||
const path = peer.as_path || "";
|
||||
const pathArr = path.split(" ").map(Number).filter(Boolean);
|
||||
if (pathArr.length > 1) {
|
||||
asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
|
||||
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);
|
||||
|
||||
// Check Cloudflare RPKI feed for ASPA object
|
||||
await ensureAspaCache();
|
||||
const aspaLookup = lookupAspaFromRpki(rawAsn);
|
||||
const aspaObjectExists = aspaLookup.exists;
|
||||
const aspaDeclaredProviders = aspaLookup.providers;
|
||||
|
||||
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
|
||||
let recommendedAspa =
|
||||
"aut-num: AS" + rawAsn + "\n" +
|
||||
"# Recommended ASPA object:\n" +
|
||||
"# customer: AS" + rawAsn + "\n" +
|
||||
"# provider-set: " + providerList + "\n" +
|
||||
"# AFI: ipv4, ipv6\n" +
|
||||
"#\n" +
|
||||
"# Detected providers from BGP path analysis:\n" +
|
||||
detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
|
||||
|
||||
// If ASPA object exists, show RPKI-declared providers
|
||||
if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
|
||||
recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
|
||||
aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
|
||||
}
|
||||
|
||||
const samplePaths = asPaths.slice(0, 10).map((p) => {
|
||||
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
|
||||
const idx = p.path.indexOf(parseInt(rawAsn));
|
||||
const provider = idx > 0 ? p.path[idx - 1] : null;
|
||||
return {
|
||||
rrc: p.rrc,
|
||||
prefix: p.prefix,
|
||||
path: pathStr,
|
||||
detected_provider: provider ? "AS" + provider : null,
|
||||
provider_in_set: provider ? upstreamSet.has(provider) : false,
|
||||
};
|
||||
});
|
||||
|
||||
if (_aspaDone) return; // hard timeout already responded
|
||||
_aspaDone = true;
|
||||
clearTimeout(_aspaTimer);
|
||||
const duration = Date.now() - start;
|
||||
const aspaResult = {
|
||||
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
asn: parseInt(rawAsn),
|
||||
detected_providers: detectedProviders,
|
||||
provider_count: detectedProviders.length,
|
||||
aspa_object_exists: aspaObjectExists,
|
||||
aspa_feed_healthy: aspaLookup.feedLoaded,
|
||||
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
||||
aspa_declared_count: aspaDeclaredProviders.length,
|
||||
recommended_aspa: recommendedAspa,
|
||||
path_analysis: {
|
||||
total_paths_seen: asPaths.length,
|
||||
sample_paths: samplePaths,
|
||||
},
|
||||
};
|
||||
resultCacheSet(aspaResultCache, rawAsn, aspaResult);
|
||||
return res.end(JSON.stringify(aspaResult, null, 2));
|
||||
} catch (err) {
|
||||
if (!_aspaDone) {
|
||||
_aspaDone = true;
|
||||
clearTimeout(_aspaTimer);
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleAspaLegacy };
|
||||
271
server/routes/aspa-verify.js
Normal file
271
server/routes/aspa-verify.js
Normal file
@ -0,0 +1,271 @@
|
||||
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 };
|
||||
108
server/routes/bgp.js
Normal file
108
server/routes/bgp.js
Normal file
@ -0,0 +1,108 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { bgproutesResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
|
||||
// Queries local PostgreSQL bgp_routes table — zero external API calls
|
||||
async function handleBgp(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
const prefix = url.searchParams.get("prefix") || "";
|
||||
if (!rawAsn && !prefix) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
|
||||
}
|
||||
const cacheKey = rawAsn || prefix;
|
||||
const cached = resultCacheGet(bgproutesResultCache, cacheKey);
|
||||
if (cached !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = {
|
||||
meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
|
||||
bgp_status: null,
|
||||
threat_intel: null,
|
||||
};
|
||||
|
||||
// ---- BGP Status (local DB lookup) ----
|
||||
if (prefix) {
|
||||
// Prefix lookup: Get BGP status for this prefix
|
||||
const bgpStatus = await localDb.getBgpStatus(prefix);
|
||||
if (bgpStatus) {
|
||||
result.bgp_status = {
|
||||
prefix,
|
||||
announced: bgpStatus.announced,
|
||||
origin_asns: bgpStatus.origin_asns,
|
||||
visibility_percent: bgpStatus.visibility_percent,
|
||||
last_seen: bgpStatus.last_seen,
|
||||
source: "local_bgp",
|
||||
};
|
||||
// Check for hijack (multiple origin ASNs). null means the check itself
|
||||
// failed (DB error) -- distinct from a genuine single/no-origin result.
|
||||
const hijackAsns = await localDb.checkBgpHijack(prefix);
|
||||
if (hijackAsns === null) {
|
||||
result.bgp_status.hijack_check_unavailable = true;
|
||||
} else if (hijackAsns.length > 1) {
|
||||
result.bgp_status.hijack_warning = {
|
||||
detected: true,
|
||||
origin_asns: hijackAsns,
|
||||
message: `Multiple origin ASNs detected for ${prefix}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (rawAsn) {
|
||||
// ASN lookup: Get all announced prefixes for this ASN
|
||||
const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
|
||||
if (prefixes && prefixes.length > 0) {
|
||||
result.bgp_status = {
|
||||
asn: rawAsn,
|
||||
announced_count: prefixes.length,
|
||||
prefixes: prefixes.slice(0, 50).map((p) => ({
|
||||
prefix: p.prefix,
|
||||
origin_asn: p.origin_asn,
|
||||
visibility_percent: p.visibility_percent,
|
||||
last_seen: p.last_seen,
|
||||
})),
|
||||
source: "local_bgp",
|
||||
};
|
||||
} else {
|
||||
result.bgp_status = {
|
||||
asn: rawAsn,
|
||||
announced: false,
|
||||
announced_count: 0,
|
||||
message: "No prefixes found for this ASN in local BGP table",
|
||||
source: "local_bgp",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Threat Intelligence (local cache lookup) ----
|
||||
// If we have an IP context, look up threat intel
|
||||
if (prefix && prefix.includes(".")) {
|
||||
// Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
|
||||
const ipAddr = prefix.split("/")[0];
|
||||
const threat = await localDb.getThreatIntel(ipAddr);
|
||||
if (threat) {
|
||||
result.threat_intel = {
|
||||
ip_address: threat.ip_address,
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
cached_at: threat.cached_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
result.meta.duration_ms = Date.now() - start;
|
||||
resultCacheSet(bgproutesResultCache, cacheKey, result);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
console.error("[/api/bgp] Error:", err.message);
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleBgp };
|
||||
46
server/routes/changelog.js
Normal file
46
server/routes/changelog.js
Normal file
@ -0,0 +1,46 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// Changelog page: renders CHANGELOG.md as HTML
|
||||
function handleChangelog(req, res) {
|
||||
try {
|
||||
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
|
||||
const lines = md.split('\n');
|
||||
let html = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('## ')) {
|
||||
html += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
|
||||
} else if (line.startsWith('### ')) {
|
||||
html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
|
||||
} else if (line.startsWith('- **')) {
|
||||
const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
|
||||
html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
|
||||
} else if (line.startsWith('- ')) {
|
||||
html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
|
||||
} else if (line.startsWith('# ')) {
|
||||
html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
|
||||
}
|
||||
}
|
||||
const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
|
||||
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
|
||||
a{color:var(--purple);text-decoration:none}
|
||||
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
|
||||
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
|
||||
</style></head><body>
|
||||
<a href="/" class="back">← peercortex.org</a>
|
||||
${html}
|
||||
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
|
||||
</body></html>`;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.writeHead(200);
|
||||
return res.end(page);
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end('Changelog not available');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleChangelog };
|
||||
175
server/routes/compare.js
Normal file
175
server/routes/compare.js
Normal file
@ -0,0 +1,175 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
const { validateRPKIWithCache } = require("../services/rpki");
|
||||
const { cacheGet, cacheSet, CACHE_TTL_DEFAULT } = require("../caches/response-caches");
|
||||
|
||||
// Compare endpoint: /api/compare?asn1=X&asn2=Y
|
||||
async function handleCompare(req, res, url) {
|
||||
const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
|
||||
const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
|
||||
if (!asn1 || !asn2) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
|
||||
}
|
||||
|
||||
const compareCacheKey = "compare:" + asn1 + ":" + asn2;
|
||||
const compareCached = cacheGet(compareCacheKey);
|
||||
if (compareCached) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(compareCached));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
// ALL calls in parallel — single batch
|
||||
// Phase 1: Get PDB net objects + RIPE data
|
||||
const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
|
||||
fetchPeeringDB("/net?asn=" + asn1),
|
||||
fetchPeeringDB("/net?asn=" + asn2),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
||||
]);
|
||||
|
||||
const net1 = pdb1?.data?.[0] || {};
|
||||
const net2 = pdb2?.data?.[0] || {};
|
||||
const netId1 = net1.id;
|
||||
const netId2 = net2.id;
|
||||
|
||||
// Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
|
||||
const ixFacPromises = [];
|
||||
ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
|
||||
ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
|
||||
ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
|
||||
ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
|
||||
const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
|
||||
|
||||
const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
|
||||
const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
|
||||
const ix1Names = {};
|
||||
(ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
|
||||
const ix2Names = {};
|
||||
(ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
|
||||
|
||||
const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
|
||||
const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
|
||||
const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
|
||||
|
||||
const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
|
||||
const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
|
||||
const fac1Names = {};
|
||||
(fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
|
||||
const fac2Names = {};
|
||||
(fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
|
||||
|
||||
const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
|
||||
|
||||
const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
||||
const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
||||
const up1Set = new Set(nb1.map((n) => n.asn));
|
||||
const up2Set = new Set(nb2.map((n) => n.asn));
|
||||
const nb1Map = {};
|
||||
nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
|
||||
const nb2Map = {};
|
||||
nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
|
||||
|
||||
const commonUpstreams = [...up1Set]
|
||||
.filter((a) => up2Set.has(a))
|
||||
.map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
|
||||
|
||||
// ---- Threat Intelligence Enrichment for Common Upstreams ----
|
||||
const threatMap = {};
|
||||
const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
|
||||
try {
|
||||
const asNum = String(n.asn);
|
||||
const threat = await localDb.getThreatIntel(asNum);
|
||||
if (threat) {
|
||||
threatMap[n.asn] = {
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
||||
}
|
||||
});
|
||||
await Promise.race([
|
||||
Promise.all(threatPromises),
|
||||
new Promise(r => setTimeout(r, 2000)),
|
||||
]);
|
||||
|
||||
// Attach threat status to upstream objects
|
||||
commonUpstreams.forEach((n) => {
|
||||
if (threatMap[n.asn]) {
|
||||
n.threat_level = threatMap[n.asn].threat_level;
|
||||
n.threat_confidence = threatMap[n.asn].confidence_score;
|
||||
n.threat_source = threatMap[n.asn].source;
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
|
||||
const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
||||
const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
||||
const [, rpki1Results, rpki2Results] = await Promise.race([
|
||||
Promise.all([
|
||||
commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
||||
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
||||
.catch(() => {})
|
||||
)) : Promise.resolve([]),
|
||||
Promise.all(pfx1.map((p) => validateRPKIWithCache(asn1, p))),
|
||||
Promise.all(pfx2.map((p) => validateRPKIWithCache(asn2, p))),
|
||||
]),
|
||||
new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
|
||||
]);
|
||||
|
||||
const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
|
||||
const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
|
||||
const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
|
||||
const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
|
||||
|
||||
const duration = Date.now() - start;
|
||||
const compareResult = {
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
asn1: {
|
||||
asn: parseInt(asn1),
|
||||
name: net1.name || "Unknown",
|
||||
ix_count: ix1Set.size,
|
||||
fac_count: fac1Set.size,
|
||||
upstream_count: up1Set.size,
|
||||
rpki_coverage: rpki1Pct,
|
||||
},
|
||||
asn2: {
|
||||
asn: parseInt(asn2),
|
||||
name: net2.name || "Unknown",
|
||||
ix_count: ix2Set.size,
|
||||
fac_count: fac2Set.size,
|
||||
upstream_count: up2Set.size,
|
||||
rpki_coverage: rpki2Pct,
|
||||
},
|
||||
common_ixps: commonIX,
|
||||
only_asn1_ixps: only1IX,
|
||||
only_asn2_ixps: only2IX,
|
||||
common_facilities: commonFac,
|
||||
common_upstreams: commonUpstreams,
|
||||
rpki_comparison: {
|
||||
asn1_coverage: rpki1Pct,
|
||||
asn2_coverage: rpki2Pct,
|
||||
asn1_checked: rpki1Results.length,
|
||||
asn2_checked: rpki2Results.length,
|
||||
better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
|
||||
},
|
||||
};
|
||||
cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
|
||||
res.end(JSON.stringify(compareResult, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
|
||||
}
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleCompare };
|
||||
125
server/routes/enrich.js
Normal file
125
server/routes/enrich.js
Normal file
@ -0,0 +1,125 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const { fetchJSON } = require("../services/http-helpers");
|
||||
|
||||
// Feature 28b: Company enrichment via Wikipedia + website meta scraping
|
||||
async function handleEnrich(req, res, url) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
const companyName = (url.searchParams.get("name") || "").trim();
|
||||
const website = (url.searchParams.get("website") || "").trim();
|
||||
const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";
|
||||
|
||||
let description = null;
|
||||
let wikiUrl = null;
|
||||
|
||||
try {
|
||||
// 1. Direct Wikipedia lookup by company name
|
||||
if (companyName) {
|
||||
const nameLower = companyName.toLowerCase();
|
||||
const isRelevant = (title, extract) => {
|
||||
const titleLower = (title || "").toLowerCase();
|
||||
const extractLower = (extract || "").toLowerCase();
|
||||
const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
|
||||
const titleMatch = nameWords.some(w => titleLower.includes(w));
|
||||
const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
|
||||
const hasNetContext = netTerms.some(t => extractLower.includes(t));
|
||||
return titleMatch && (hasNetContext || titleMatch);
|
||||
};
|
||||
|
||||
// Direct title lookup — try full name first, then first word as fallback
|
||||
const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
|
||||
const firstName = cleanName.split(/\s+/)[0];
|
||||
const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
|
||||
for (const tryName of namesToTry) {
|
||||
const wikiDirect = await fetchJSON(
|
||||
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
|
||||
if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
|
||||
description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
|
||||
wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wikipedia search if direct lookup didn't match
|
||||
if (!description) {
|
||||
const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
|
||||
const searchData = await fetchJSON(
|
||||
"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
|
||||
const topTitle = searchData[1][0];
|
||||
const wikiSearch = await fetchJSON(
|
||||
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
|
||||
if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
|
||||
description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
|
||||
wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: scrape website meta description (follows up to 3 redirects)
|
||||
function fetchPage(pageUrl, hops) {
|
||||
if (hops <= 0) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
const mod = pageUrl.startsWith("https") ? https : http;
|
||||
const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume(); // drain to free socket
|
||||
const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
|
||||
return resolve(fetchPage(next, hops - 1));
|
||||
}
|
||||
let data = "";
|
||||
res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
|
||||
res.on("end", () => resolve(data));
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
});
|
||||
}
|
||||
if (!description && website) {
|
||||
let wsUrl = website;
|
||||
if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
|
||||
const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
|
||||
const tryUrls = [aboutUrl, wsUrl];
|
||||
for (const tryUrl of tryUrls) {
|
||||
const resp = await fetchPage(tryUrl, 3);
|
||||
if (!resp) continue;
|
||||
const metaPatterns = [
|
||||
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
|
||||
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
|
||||
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
|
||||
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
|
||||
];
|
||||
let matched = false;
|
||||
for (const pat of metaPatterns) {
|
||||
const m = resp.match(pat);
|
||||
if (m && m[1]) {
|
||||
description = m[1].replace(/ |✓|&/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Return whatever we have
|
||||
}
|
||||
|
||||
return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleEnrich };
|
||||
70
server/routes/feedback.js
Normal file
70
server/routes/feedback.js
Normal file
@ -0,0 +1,70 @@
|
||||
const fs = require("fs");
|
||||
const { sendFeedbackMail } = require("../../src/backend/services/smtp");
|
||||
|
||||
const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
|
||||
const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
|
||||
|
||||
function handleOptions(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// POST /api/feedback — submit feedback entry
|
||||
function handlePost(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
let body = '';
|
||||
req.on('data', function(chunk) { body += chunk; });
|
||||
req.on('end', function() {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (!data.message || String(data.message).trim().length < 3) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ ok: false, error: 'Message too short' }));
|
||||
}
|
||||
const entry = {
|
||||
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
timestamp: new Date().toISOString(),
|
||||
category: String(data.category || 'General').slice(0, 50),
|
||||
message: String(data.message || '').slice(0, 2000),
|
||||
name: String(data.name || 'Anonymous').slice(0, 100),
|
||||
asn: data.asn ? String(data.asn).slice(0, 20) : null,
|
||||
ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null,
|
||||
ua: String(req.headers['user-agent'] || '').slice(0, 200)
|
||||
};
|
||||
let entries = [];
|
||||
try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ }
|
||||
entries.push(entry);
|
||||
fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2));
|
||||
// Send email async — don't block response
|
||||
sendFeedbackMail(entry).catch(e => console.error('[MAIL] Failed:', e.message));
|
||||
return res.end(JSON.stringify({ ok: true, id: entry.id }));
|
||||
} catch (_e) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ ok: false, error: 'Server error' }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/feedback?token=... — admin: fetch all entries as JSON
|
||||
function handleGet(req, res, url) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token || token !== FEEDBACK_TOKEN) {
|
||||
res.writeHead(401);
|
||||
return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
|
||||
}
|
||||
try {
|
||||
const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8'));
|
||||
return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length }));
|
||||
} catch (_e) {
|
||||
return res.end(JSON.stringify({ ok: true, entries: [], count: 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleOptions, handlePost, handleGet };
|
||||
104
server/routes/health.js
Normal file
104
server/routes/health.js
Normal file
@ -0,0 +1,104 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { getRpkiAspaLastFetch, rpkiAspaMap } = require("../services/rpki");
|
||||
const { pdbSourceCache } = require("../caches/pdb-source-cache");
|
||||
const { roaStore } = require("../caches/roa-store");
|
||||
const { ripeStatCache } = require("../services/ripe-stat");
|
||||
const { responseCache } = require("../caches/response-caches");
|
||||
const { aspaAdoptionDailyHistory } = require("../aspa-adoption/tracker");
|
||||
|
||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||
|
||||
function aspaAdoptionDelta() {
|
||||
return aspaAdoptionDailyHistory.length >= 2
|
||||
? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Health endpoint — extended with cache status, ASPA metrics, and local DB stats
|
||||
function handleHealth(req, res) {
|
||||
const mem = process.memoryUsage();
|
||||
const rpkiAspaLastFetch = getRpkiAspaLastFetch();
|
||||
const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
|
||||
// Bug fix 2026-07-17: previously only declared inside the .then() callback
|
||||
// below, so the .catch() branch's reference to it threw ReferenceError
|
||||
// (never caught -- the response just hung until client timeout).
|
||||
const roaAge = roaStore.lastBuild ? Math.floor((Date.now() - roaStore.lastBuild) / 60000) : -1;
|
||||
const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
|
||||
|
||||
// Query local DB stats (async, but return partial if needed)
|
||||
localDb.getLocalDbStats().then(function(dbStats) {
|
||||
// Determine health status based on local DB data availability
|
||||
const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
|
||||
const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
|
||||
const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";
|
||||
|
||||
const healthResponse = {
|
||||
status,
|
||||
service: "PeerCortex",
|
||||
version: "0.6.9",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
|
||||
bgproutes_configured: !!BGPROUTES_API_KEY,
|
||||
caches: {
|
||||
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
|
||||
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
|
||||
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
|
||||
pdb_netfac: { entries: pdbSourceCache.netfac.size },
|
||||
ripe_stat: { entries: ripeStatCache.size },
|
||||
response_cache: { entries: responseCache.size },
|
||||
},
|
||||
local_db: dbStats ? {
|
||||
bgp_routes: dbStats.bgp_routes,
|
||||
rpki_roas: dbStats.rpki_roas,
|
||||
threat_intel: dbStats.threat_intel,
|
||||
rdap_cache_entries: dbStats.rdap_cache_entries,
|
||||
source: "PostgreSQL (local)",
|
||||
healthy: hasLocalBgp && hasLocalRpki,
|
||||
} : null,
|
||||
aspa_adoption: {
|
||||
total_objects: rpkiAspaMap.size,
|
||||
roa_count: roaStore.count,
|
||||
history_samples: aspaAdoptionDailyHistory.length,
|
||||
delta_last: aspaAdoptionDelta(),
|
||||
},
|
||||
};
|
||||
return res.end(JSON.stringify(healthResponse, null, 2));
|
||||
}).catch(function(e) {
|
||||
console.error('[/api/health] Local DB stats error:', e.message);
|
||||
// Return health without local DB stats on error. Local DB stats failed
|
||||
// to fetch, so "ok" can't be confirmed -- report degraded, matching the
|
||||
// honest-degradation pattern used throughout this codebase.
|
||||
const status = "degraded";
|
||||
return res.end(
|
||||
JSON.stringify({
|
||||
status,
|
||||
service: "PeerCortex",
|
||||
version: "0.6.9",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
|
||||
bgproutes_configured: !!BGPROUTES_API_KEY,
|
||||
caches: {
|
||||
roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
|
||||
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
|
||||
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
|
||||
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
|
||||
pdb_netfac: { entries: pdbSourceCache.netfac.size },
|
||||
ripe_stat: { entries: ripeStatCache.size },
|
||||
response_cache: { entries: responseCache.size },
|
||||
},
|
||||
local_db: { error: "Could not fetch local DB stats", message: e.message },
|
||||
aspa_adoption: {
|
||||
total_objects: rpkiAspaMap.size,
|
||||
roa_count: roaStore.count,
|
||||
history_samples: aspaAdoptionDailyHistory.length,
|
||||
delta_last: aspaAdoptionDelta(),
|
||||
},
|
||||
}, null, 2)
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = { handleHealth };
|
||||
221
server/routes/hijack-alerts.js
Normal file
221
server/routes/hijack-alerts.js
Normal file
@ -0,0 +1,221 @@
|
||||
const crypto = require("crypto");
|
||||
const {
|
||||
loadHijackSubs, loadHijackAlerts, loadWebhookSubs,
|
||||
saveWebhookSubs, saveHijackAlerts, saveHijackSubs,
|
||||
} = require("../hijack-monitoring/store");
|
||||
const { deliverWebhook } = require("../hijack-monitoring/webhooks");
|
||||
const { checkHijacksForAsn } = require("../hijack-monitoring/monitor");
|
||||
|
||||
function matchesHijackGroup(reqPath) {
|
||||
return reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe';
|
||||
}
|
||||
|
||||
// CORS preflight for all /api/webhooks + /api/hijacks
|
||||
function handleOptions(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.writeHead(204); return res.end();
|
||||
}
|
||||
|
||||
// Webhook: Register. POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
|
||||
function handleWebhookRegister(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
||||
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); }
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}');
|
||||
if (!endpoint_url || !endpoint_url.startsWith('http')) {
|
||||
res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'}));
|
||||
}
|
||||
const subs = loadWebhookSubs();
|
||||
const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url);
|
||||
if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); }
|
||||
const webhookToken = crypto.randomBytes(32).toString('hex');
|
||||
const entry = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
asn, endpoint_url,
|
||||
secret: webhookToken,
|
||||
timeout_ms: timeout_ms || 10000,
|
||||
max_retries: max_retries || 5,
|
||||
active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
last_triggered_at: null,
|
||||
failure_count: 0
|
||||
};
|
||||
subs.push(entry);
|
||||
saveWebhookSubs(subs);
|
||||
// Also ensure this ASN is in hijack monitoring
|
||||
const hijackSubs = loadHijackSubs();
|
||||
if (!hijackSubs.find(s => s.asn === asn)) {
|
||||
checkHijacksForAsn(asn).then(prefixes => {
|
||||
// null (lookup failed) becomes [] here so this record's shape stays
|
||||
// consistent; runHijackCheck retries the real baseline next cycle.
|
||||
hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
||||
saveHijackSubs(hijackSubs);
|
||||
});
|
||||
}
|
||||
res.writeHead(201);
|
||||
// Bug fix 2026-07-17 (agreed pre-existing bug #1 of 3): responded with
|
||||
// `secret_key: secret`, but `secret` was never declared in this scope
|
||||
// (only `webhookToken` and `entry.secret`, the same value) -- every new
|
||||
// webhook registration threw ReferenceError here, caught below,
|
||||
// degrading to a 400 "secret is not defined". Use entry.secret, matching
|
||||
// the working "already exists" branch above.
|
||||
res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: entry.secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' }));
|
||||
} catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Webhook: List. GET /api/webhooks?asn=X
|
||||
function handleWebhookList(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
||||
const subs = loadWebhookSubs();
|
||||
const result = (asn ? subs.filter(s => s.asn === asn) : subs)
|
||||
.map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at }));
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ webhooks: result, total: result.length }));
|
||||
}
|
||||
|
||||
// Webhook: Delete. DELETE /api/webhooks/:id
|
||||
function handleWebhookDelete(req, res, reqPath) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const id = reqPath.split('/')[3];
|
||||
const subs = loadWebhookSubs();
|
||||
const idx = subs.findIndex(s => s.id === id);
|
||||
if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
|
||||
subs.splice(idx, 1);
|
||||
saveWebhookSubs(subs);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ deleted: true, id }));
|
||||
}
|
||||
|
||||
// Webhook: Test. POST /api/webhooks/:id/test
|
||||
function handleWebhookTest(req, res, reqPath) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const id = reqPath.split('/')[3];
|
||||
const subs = loadWebhookSubs();
|
||||
const sub = subs.find(s => s.id === id);
|
||||
if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
|
||||
const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() };
|
||||
const start = Date.now();
|
||||
deliverWebhook(sub, testPayload, 1).then(result => {
|
||||
res.writeHead(result.ok ? 200 : 502);
|
||||
res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false
|
||||
function handleHijacksList(req, res) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
||||
const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);
|
||||
const resolvedFilter = params.get('resolved');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
let events = loadHijackAlerts();
|
||||
if (asn) events = events.filter(a => String(a.asn) === asn);
|
||||
if (resolvedFilter === 'false') events = events.filter(a => !a.resolved);
|
||||
if (resolvedFilter === 'true') events = events.filter(a => a.resolved);
|
||||
const total = events.length;
|
||||
events = events.slice(-limit).reverse();
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ total, events }));
|
||||
}
|
||||
|
||||
// Hijack Events: Resolve. POST /api/hijacks/:id/resolve body: { resolution_notes? }
|
||||
function handleHijackResolve(req, res, reqPath) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const id = reqPath.split('/')[3];
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
const alerts = loadHijackAlerts();
|
||||
const alert = alerts.find(a => a.id === id);
|
||||
if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); }
|
||||
alert.resolved = true;
|
||||
alert.resolved_at = new Date().toISOString();
|
||||
try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){}
|
||||
saveHijackAlerts(alerts);
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack Subscribe (legacy, kept for backwards compatibility)
|
||||
function handleHijackSubscribe(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { asn } = JSON.parse(body || '{}');
|
||||
const asnNum = String(asn || '').replace(/[^0-9]/g,'');
|
||||
if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
|
||||
const subs = loadHijackSubs();
|
||||
const exists = subs.find(s => s.asn === asnNum);
|
||||
if (!exists) {
|
||||
const prefixes = await checkHijacksForAsn(asnNum);
|
||||
// prefixes may be null if the lookup failed just now -- store an empty
|
||||
// array so this record shape stays consistent; runHijackCheck's own
|
||||
// "!sub.prefixes.length" check will retry establishing the real
|
||||
// baseline on its next cycle rather than treating this as permanent.
|
||||
subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
||||
saveHijackSubs(subs);
|
||||
}
|
||||
const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
|
||||
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack Alerts (legacy endpoint, kept for backwards compatibility).
|
||||
// src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under
|
||||
// the same paths this file already serves above (file-backed, live).
|
||||
// Deliberately NOT proxied here — that would shadow working data with an
|
||||
// empty Postgres-backed implementation. Not a gap, just two feature
|
||||
// branches that ended up naming their routes the same thing.
|
||||
function handleHijackAlertsLegacy(req, res) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const allAlerts = loadHijackAlerts();
|
||||
const alerts = asn ? allAlerts.filter(a => String(a.asn) === asn) : allAlerts;
|
||||
const subs = loadHijackSubs();
|
||||
const sub = subs.find(s => s.asn === asn);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
matchesHijackGroup,
|
||||
handleOptions,
|
||||
handleWebhookRegister,
|
||||
handleWebhookList,
|
||||
handleWebhookDelete,
|
||||
handleWebhookTest,
|
||||
handleHijacksList,
|
||||
handleHijackResolve,
|
||||
handleHijackSubscribe,
|
||||
handleHijackAlertsLegacy,
|
||||
};
|
||||
59
server/routes/ix-detail.js
Normal file
59
server/routes/ix-detail.js
Normal file
@ -0,0 +1,59 @@
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
|
||||
// IX Detail endpoint: /api/ix/detail?ix_id=X
|
||||
async function handleIxDetail(req, res, url) {
|
||||
const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
|
||||
if (!ixId) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const [ixData, ixlanData] = await Promise.all([
|
||||
fetchPeeringDB("/ix/" + ixId),
|
||||
fetchPeeringDB("/ixlan?ix_id=" + ixId),
|
||||
]);
|
||||
|
||||
const ix = ixData?.data?.[0] || {};
|
||||
const ixlans = ixlanData?.data || [];
|
||||
const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;
|
||||
|
||||
let members = [];
|
||||
if (ixlanId) {
|
||||
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
|
||||
members = (netixlanData?.data || []).map(m => ({
|
||||
asn: m.asn,
|
||||
name: m.name || "",
|
||||
speed_mbps: m.speed || 0,
|
||||
speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
|
||||
ipv4: m.ipaddr4 || null,
|
||||
ipv6: m.ipaddr6 || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by speed desc for top members
|
||||
const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
return res.end(JSON.stringify({
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
ix: {
|
||||
id: parseInt(ixId),
|
||||
name: ix.name || "",
|
||||
city: ix.city || "",
|
||||
country: ix.country || "",
|
||||
website: ix.website || "",
|
||||
peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
|
||||
},
|
||||
total_members: members.length,
|
||||
top_members_by_speed: sorted.slice(0, 20),
|
||||
all_members: sorted,
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleIxDetail };
|
||||
98
server/routes/lia.js
Normal file
98
server/routes/lia.js
Normal file
@ -0,0 +1,98 @@
|
||||
const { atlasState } = require("../atlas-probes");
|
||||
const { pdbOrgState } = require("../pdb-org-countries");
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
const { cacheGet, cacheSet } = require("../caches/response-caches");
|
||||
|
||||
// Lia's Atlas Paradise: Atlas probe coverage endpoint
|
||||
function handleAtlasCoverage(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (!atlasState.cache) {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
|
||||
}
|
||||
return res.end(JSON.stringify(atlasState.cache, null, 2));
|
||||
}
|
||||
|
||||
// Lia's Paradise: File parsing endpoint (for binary uploads)
|
||||
function handleParseFile(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
let body = "";
|
||||
req.on("data", function(chunk) { body += chunk; });
|
||||
req.on("end", function() {
|
||||
try {
|
||||
var parsed = JSON.parse(body);
|
||||
var filename = parsed.filename || "";
|
||||
var ext = filename.split(".").pop().toLowerCase();
|
||||
// For text-based formats, decode base64 and extract text
|
||||
if (ext === "csv" || ext === "txt") {
|
||||
var text = Buffer.from(parsed.data, "base64").toString("utf8");
|
||||
return res.end(JSON.stringify({ text: text }));
|
||||
}
|
||||
// For binary formats (PDF, XLS, DOC), we can't parse server-side without
|
||||
// heavy dependencies. Return helpful error.
|
||||
return res.end(JSON.stringify({
|
||||
error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.",
|
||||
suggestion: "Export your spreadsheet as CSV first, then upload the CSV file."
|
||||
}));
|
||||
} catch(e) {
|
||||
return res.end(JSON.stringify({ error: "Parse error: " + e.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Lia's Paradise: Combined PeeringDB + Atlas coverage data
|
||||
function handleLiaCoverage(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (!atlasState.cache) {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
|
||||
}
|
||||
|
||||
// Cache this expensive response for 30 min
|
||||
var liaCacheKey = "lia_coverage";
|
||||
var liaCached = cacheGet(liaCacheKey);
|
||||
if (liaCached) return res.end(liaCached);
|
||||
|
||||
// Fetch PeeringDB network list (all networks with status "ok")
|
||||
// Use pre-cached org→country map (loaded at startup, 16MB response cached in memory)
|
||||
fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
|
||||
if (!pdbData || !pdbData.data) {
|
||||
return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
|
||||
}
|
||||
|
||||
var probeAsns = new Set(atlasState.cache.asns_with_probes || []);
|
||||
|
||||
var enriched = pdbData.data.map(function(n) {
|
||||
var org = pdbOrgState.map.get(n.org_id) || {};
|
||||
var cc = org.country || "";
|
||||
return {
|
||||
asn: n.asn,
|
||||
name: n.name || "",
|
||||
org_name: org.name || "",
|
||||
country: cc,
|
||||
country_name: cc,
|
||||
info_type: n.info_type || "",
|
||||
has_probe: probeAsns.has(n.asn),
|
||||
};
|
||||
}).filter(function(n) { return n.asn > 0 && n.country; });
|
||||
|
||||
var result = JSON.stringify({
|
||||
networks: enriched,
|
||||
total: enriched.length,
|
||||
with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
|
||||
without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
|
||||
atlas_unique_asns: probeAsns.size,
|
||||
org_countries_loaded: pdbOrgState.map.size,
|
||||
fetched_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
cacheSet(liaCacheKey, result, 30 * 60 * 1000);
|
||||
res.end(result);
|
||||
}).catch(function(e) {
|
||||
res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = { handleAtlasCoverage, handleParseFile, handleLiaCoverage };
|
||||
90
server/routes/lookup/cross-check.js
Normal file
90
server/routes/lookup/cross-check.js
Normal file
@ -0,0 +1,90 @@
|
||||
const { crossCheckRpki } = require("../../services/rpki");
|
||||
|
||||
// Multi-source cross-checks (RPKI vs RIPE Validator, prefix/neighbour counts
|
||||
// vs bgp.he.net) plus the overall data-quality summary derived from them.
|
||||
async function computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData) {
|
||||
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_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 */ }
|
||||
|
||||
// bgp.he.net fetching is deliberately disabled (see the hardcoded
|
||||
// Promise.resolve(null) in index.js's timedFetch("bgp.he.net", ...)) --
|
||||
// bgpHeData is therefore always null and this whole branch, along with the
|
||||
// prefix/neighbour cross-checks below, never actually runs. Left in place
|
||||
// (not deleted) in case bgp.he.net fetching is re-enabled later, but
|
||||
// overall_confidence below must not claim a 2-source agreement that never
|
||||
// happened -- see the sources:1/2 handling further down.
|
||||
// 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. Only push a score for a cross-check that
|
||||
// actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
|
||||
// not silently count as a perfect 100 in the average, and confidence must
|
||||
// reflect "nothing was actually cross-checked" rather than defaulting high.
|
||||
const crossCheckScores = [];
|
||||
if (rpkiCrossCheck.agreement_pct !== null) 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)
|
||||
: null;
|
||||
const overallConfidence = avgAgreement === null ? "unknown" : 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: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
|
||||
prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, 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: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, 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,
|
||||
};
|
||||
|
||||
return { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality };
|
||||
}
|
||||
|
||||
module.exports = { computeCrossChecks };
|
||||
73
server/routes/lookup/enrich-neighbours.js
Normal file
73
server/routes/lookup/enrich-neighbours.js
Normal file
@ -0,0 +1,73 @@
|
||||
const localDb = require("../../../local-db-client");
|
||||
const { fetchRipeStatCached } = require("../../services/ripe-stat");
|
||||
|
||||
// Resolve empty AS names — all in parallel, with 3s timeout. Mutates entries
|
||||
// in place (matches original control flow).
|
||||
async function resolveEmptyNames(neighbourLists) {
|
||||
const emptyNameNeighbours = neighbourLists.flat().filter(n => !n.name);
|
||||
if (emptyNameNeighbours.length === 0) return;
|
||||
const resolvePromise = Promise.all(
|
||||
emptyNameNeighbours.map(n =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
||||
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
||||
.catch(() => {})
|
||||
)
|
||||
);
|
||||
await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
|
||||
}
|
||||
|
||||
// Threat Intelligence Enrichment for Neighbors: enrich neighbor data with
|
||||
// threat status from the local threat_intel table. Batch lookups (cap at 50
|
||||
// to avoid overwhelming DB), 4s timeout.
|
||||
async function buildThreatMap(neighbors) {
|
||||
const threatMap = {};
|
||||
const toCheck = neighbors.slice(0, 50);
|
||||
const threatPromises = toCheck.map(async (n) => {
|
||||
try {
|
||||
const asNum = String(n.asn);
|
||||
const threat = await localDb.getThreatIntel(asNum);
|
||||
if (threat) {
|
||||
threatMap[n.asn] = {
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
cached_at: threat.cached_at,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Gracefully skip on error
|
||||
console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(threatPromises),
|
||||
new Promise(r => setTimeout(r, 4000)),
|
||||
]);
|
||||
|
||||
return threatMap;
|
||||
}
|
||||
|
||||
function attachThreat(n, threatMap) {
|
||||
return {
|
||||
...n,
|
||||
threat_level: threatMap[n.asn]?.threat_level || null,
|
||||
threat_confidence: threatMap[n.asn]?.confidence_score || null,
|
||||
threat_source: threatMap[n.asn]?.source || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Full enrichment pipeline: resolve empty names, then attach threat intel to
|
||||
// upstreams/downstreams/peers. Returns new arrays (does not mutate the input
|
||||
// arrays themselves, though resolveEmptyNames does mutate entry.name).
|
||||
async function enrichNeighbours(upstreams, downstreams, peers) {
|
||||
await resolveEmptyNames([upstreams, downstreams, peers]);
|
||||
const threatMap = await buildThreatMap([...upstreams, ...downstreams, ...peers]);
|
||||
return {
|
||||
upstreams: upstreams.map(n => attachThreat(n, threatMap)),
|
||||
downstreams: downstreams.map(n => attachThreat(n, threatMap)),
|
||||
peers: peers.map(n => attachThreat(n, threatMap)),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { enrichNeighbours };
|
||||
114
server/routes/lookup/facilities.js
Normal file
114
server/routes/lookup/facilities.js
Normal file
@ -0,0 +1,114 @@
|
||||
const { fetchPeeringDB } = require("../../services/peeringdb");
|
||||
const { CITY_COORDS } = require("../../data/city-coords");
|
||||
const { IX_CITY_MAP } = require("../../data/ix-city-map");
|
||||
|
||||
// Batch-fetch facility lat/lon by ID, 25 per request (PeeringDB /fac?id__in= limit),
|
||||
// bounded by an overall race timeout. Returns {fac_id: {lat, lon}}.
|
||||
async function fetchFacCoordsBatch(facIds, timeoutMs) {
|
||||
const coordMap = {};
|
||||
if (facIds.length === 0) return coordMap;
|
||||
try {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
|
||||
const coordResults = await Promise.race([
|
||||
Promise.all(chunks.map(chunk =>
|
||||
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
|
||||
)),
|
||||
new Promise(r => setTimeout(() => r([]), timeoutMs)),
|
||||
]);
|
||||
(coordResults || []).forEach(res => {
|
||||
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) coordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
|
||||
});
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
return coordMap;
|
||||
}
|
||||
|
||||
// Resolve facility coordinates for the map, then IX locations via ixfac -> fac
|
||||
// coordinates (max 20 IXs), then the name/CITY_COORDS/IX_CITY_MAP geocode
|
||||
// fallbacks for IXs PeeringDB has no facility coordinates for.
|
||||
async function resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections) {
|
||||
// Batch-fetch facility coordinates for map (max 50 facilities)
|
||||
const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
|
||||
let facCoordMap = await fetchFacCoordsBatch(facIds, 5000);
|
||||
|
||||
const facilities = facilitiesRaw.map(f => ({
|
||||
...f,
|
||||
latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
|
||||
longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
|
||||
}));
|
||||
|
||||
// Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
|
||||
const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
|
||||
let ixLocations = [];
|
||||
if (uniqueIxIds.length > 0) {
|
||||
try {
|
||||
const ixFacData = await Promise.race([
|
||||
fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
|
||||
new Promise(r => setTimeout(() => r(null), 5000)),
|
||||
]);
|
||||
const ixFacs = ixFacData?.data || [];
|
||||
// Collect unique fac_ids we don't already have coords for
|
||||
const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
|
||||
if (extraFacIds.length > 0) {
|
||||
const extraCoords = await fetchFacCoordsBatch(extraFacIds, 4000);
|
||||
facCoordMap = { ...facCoordMap, ...extraCoords };
|
||||
}
|
||||
// Build IX locations: pick first facility with coords per IX
|
||||
const ixNameMap = {};
|
||||
ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
|
||||
const seenIx = {};
|
||||
ixFacs.forEach(f => {
|
||||
if (seenIx[f.ix_id]) return;
|
||||
const coords = facCoordMap[f.fac_id];
|
||||
if (coords) {
|
||||
seenIx[f.ix_id] = true;
|
||||
ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
|
||||
}
|
||||
});
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
}
|
||||
|
||||
applyIxGeocodeFallback(ixLocations, ixConnections);
|
||||
|
||||
return { facilities, ixLocations };
|
||||
}
|
||||
|
||||
// === IX Location Geocode Fallback ===
|
||||
// Some IXPs have no facility coordinates in PeeringDB.
|
||||
// Use ix_name city extraction + hard-coded IX→city map as fallback.
|
||||
// Mutates ixLocations in place (matches original control flow).
|
||||
function applyIxGeocodeFallback(ixLocations, ixConnections) {
|
||||
var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
|
||||
ixConnections.forEach(function(conn) {
|
||||
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
||||
var name = conn.ix_name || "";
|
||||
if (name) {
|
||||
var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
|
||||
for (var w = 0; w < words.length; w++) {
|
||||
if (CITY_COORDS[words[w]]) {
|
||||
ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
|
||||
ixIdsWithCoords.add(conn.ix_id);
|
||||
return;
|
||||
}
|
||||
if (w < words.length - 1) {
|
||||
var tw = words[w] + " " + words[w + 1];
|
||||
if (CITY_COORDS[tw]) {
|
||||
ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
|
||||
ixIdsWithCoords.add(conn.ix_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Hard-coded IX ID → city for well-known IXPs whose names don't contain city
|
||||
ixConnections.forEach(function(conn) {
|
||||
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
||||
var city = IX_CITY_MAP[conn.ix_id];
|
||||
if (city && CITY_COORDS[city]) {
|
||||
ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { resolveFacilitiesAndIxLocations };
|
||||
337
server/routes/lookup/index.js
Normal file
337
server/routes/lookup/index.js
Normal file
@ -0,0 +1,337 @@
|
||||
const localDb = require("../../../local-db-client");
|
||||
const { fetchJSON } = require("../../services/http-helpers");
|
||||
const { fetchPeeringDB, fetchPeeringDBWithRetry } = require("../../services/peeringdb");
|
||||
const { ensureAspaCache, validateRPKIWithCache } = require("../../services/rpki");
|
||||
const { computeResilienceScore } = require("../../resilience-score");
|
||||
const { computeRouteLeakDetection } = require("../../route-leak");
|
||||
const { pdbSourceCache } = require("../../caches/pdb-source-cache");
|
||||
const {
|
||||
cacheGet, cacheSet, CACHE_TTL_LOOKUP, CACHE_TTL_LOOKUP_DEGRADED,
|
||||
rdapCacheGet, rdapCacheSet,
|
||||
} = require("../../caches/response-caches");
|
||||
const { resolveFacilitiesAndIxLocations } = require("./facilities");
|
||||
const { enrichNeighbours } = require("./enrich-neighbours");
|
||||
const { deriveRirAndCountry } = require("./rir-country");
|
||||
const { computeRoutingInfo } = require("./routing-info");
|
||||
const { computeCrossChecks } = require("./cross-check");
|
||||
|
||||
// Main lookup endpoint: /api/lookup?asn=X
|
||||
async function handleLookup(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 asn = rawAsn;
|
||||
const cacheKey = "lookup:" + asn;
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Phase 0: Get PDB net first — check L2 cache, then API with retry
|
||||
let pdbNet = pdbSourceCache.get("net", asn);
|
||||
if (!pdbNet) {
|
||||
pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
|
||||
if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
|
||||
}
|
||||
const net = pdbNet?.data?.[0] || {};
|
||||
const netId = net.id;
|
||||
|
||||
// Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
|
||||
const ixQuery = netId
|
||||
? "/netixlan?net_id=" + netId + "&limit=1000"
|
||||
: "/netixlan?asn=" + asn + "&limit=1000";
|
||||
const ixCacheKey = netId ? String(netId) : "asn:" + asn;
|
||||
|
||||
// Check PDB source cache for IX/Fac data
|
||||
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
|
||||
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
|
||||
|
||||
// Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
|
||||
const sourceTiming = {};
|
||||
function timedFetch(name, promise) {
|
||||
const ts = Date.now();
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
|
||||
])
|
||||
.then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
|
||||
.catch(function() { sourceTiming[name] = null; return null; });
|
||||
}
|
||||
|
||||
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
|
||||
|
||||
// RDAP: check module-level cache first, only hit RIR endpoints on cache miss
|
||||
const rdapCached = rdapCacheGet(asn);
|
||||
const rdapPromise = rdapCached !== undefined
|
||||
? Promise.resolve(rdapCached)
|
||||
: Promise.race([
|
||||
...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn,
|
||||
"https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn,
|
||||
"https://rdap.afrinic.net/rdap/autnum/"+asn].map(url =>
|
||||
fetchJSON(url, { timeout: 4000 })
|
||||
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
|
||||
.catch(() => new Promise(() => {}))
|
||||
),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 5000)),
|
||||
]).then(d => { rdapCacheSet(asn, d); return d; });
|
||||
|
||||
const promises = [
|
||||
timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat RIR", Promise.resolve(null)),
|
||||
timedFetch("RIPE Atlas", Promise.resolve(null)),
|
||||
timedFetch("bgp.he.net", Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
|
||||
timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
|
||||
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
|
||||
timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
|
||||
timedFetch("RDAP Registration", rdapPromise),
|
||||
];
|
||||
const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises);
|
||||
|
||||
// Store PDB results in L2 source cache for future lookups
|
||||
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
|
||||
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
|
||||
|
||||
// local-db-client's getRipeStat* functions return status:"error" (not "ok") when
|
||||
// the local Postgres DB query itself failed -- track which sources actually
|
||||
// failed so we don't cache a DB hiccup's fake-empty result as if it were a
|
||||
// genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
|
||||
const degradedSources = [
|
||||
["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
|
||||
["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
|
||||
["RIPE Stat PrefixSize", prefixSizeData],
|
||||
].filter(([, v]) => v && v.status === "error").map(([name]) => name);
|
||||
|
||||
const prefixes = prefixData?.data?.prefixes || [];
|
||||
const neighbours = neighbourData?.data?.neighbours || [];
|
||||
const overview = overviewData?.data || {};
|
||||
const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
|
||||
|
||||
// Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
|
||||
const atlasProbes = atlasProbeData?.results || [];
|
||||
const atlasConnected = atlasProbes.filter(p => {
|
||||
const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
|
||||
return sName === "connected";
|
||||
});
|
||||
const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
|
||||
|
||||
// RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
|
||||
await ensureAspaCache();
|
||||
const allPrefixes = prefixes.map((p) => p.prefix);
|
||||
const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
|
||||
|
||||
const ixConnections = (ixlanData?.data || [])
|
||||
.map((ix) => ({
|
||||
ix_name: ix.name || "",
|
||||
ix_id: ix.ix_id,
|
||||
speed_mbps: ix.speed || 0,
|
||||
ipv4: ix.ipaddr4 || null,
|
||||
ipv6: ix.ipaddr6 || null,
|
||||
city: ix.city || "",
|
||||
is_rs_peer: ix.is_rs_peer === true,
|
||||
}))
|
||||
.sort((a, b) => b.speed_mbps - a.speed_mbps);
|
||||
|
||||
const facilitiesRaw = (facData?.data || []).map((f) => ({
|
||||
fac_id: f.fac_id,
|
||||
name: f.name || "",
|
||||
city: f.city || "",
|
||||
country: f.country || "",
|
||||
}));
|
||||
|
||||
const { facilities, ixLocations } = await resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections);
|
||||
|
||||
const rpkiStatuses = rpkiAllResults;
|
||||
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
|
||||
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
|
||||
const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
|
||||
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
|
||||
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
||||
// must not lower a network's displayed RPKI coverage percentage.
|
||||
const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
|
||||
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
|
||||
|
||||
let upstreams = neighbours
|
||||
.filter((n) => n.type === "left")
|
||||
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
||||
.sort((a, b) => b.power - a.power);
|
||||
let downstreams = neighbours
|
||||
.filter((n) => n.type === "right")
|
||||
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
||||
.sort((a, b) => b.power - a.power);
|
||||
let peers = neighbours
|
||||
.filter((n) => n.type === "uncertain" || n.type === "peer")
|
||||
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
||||
.sort((a, b) => b.power - a.power);
|
||||
|
||||
const enriched = await enrichNeighbours(upstreams, downstreams, peers);
|
||||
upstreams = enriched.upstreams;
|
||||
downstreams = enriched.downstreams;
|
||||
peers = enriched.peers;
|
||||
|
||||
const { rir, country } = deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
const routingInfo = await computeRoutingInfo(prefixes, visibilityData, prefixSizeData);
|
||||
|
||||
const { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality } =
|
||||
await computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData);
|
||||
|
||||
const result = {
|
||||
meta: {
|
||||
service: "PeerCortex",
|
||||
version: "0.6.9",
|
||||
query: "AS" + asn,
|
||||
duration_ms: duration,
|
||||
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,
|
||||
degraded_sources: degradedSources,
|
||||
},
|
||||
network: {
|
||||
asn: parseInt(asn),
|
||||
name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown",
|
||||
aka: net.aka || "",
|
||||
org_name: (net.org && net.org.name) ? net.org.name : "",
|
||||
website: net.website || "",
|
||||
type: net.info_type || "",
|
||||
policy: net.policy_general || "",
|
||||
traffic: net.info_traffic || "",
|
||||
ratio: net.info_ratio || "",
|
||||
scope: net.info_scope || "",
|
||||
notes: net.notes ? net.notes.substring(0, 500) : "",
|
||||
peeringdb_id: netId || null,
|
||||
rir: rir,
|
||||
country: country,
|
||||
city: net.city || "",
|
||||
latitude: (net.latitude != null) ? net.latitude : null,
|
||||
longitude: (net.longitude != null) ? net.longitude : null,
|
||||
looking_glass: net.looking_glass || "",
|
||||
route_server: net.route_server || "",
|
||||
info_prefixes4: net.info_prefixes4 || 0,
|
||||
info_prefixes6: net.info_prefixes6 || 0,
|
||||
status: net.status || "",
|
||||
peeringdb_created: net.created ? net.created.slice(0, 10) : "",
|
||||
peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "",
|
||||
},
|
||||
prefixes: {
|
||||
total: prefixes.length,
|
||||
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,
|
||||
valid: rpkiValid,
|
||||
invalid: rpkiInvalid,
|
||||
not_found: rpkiNotFound,
|
||||
unavailable: rpkiUnavailable,
|
||||
checked: rpkiTotal,
|
||||
details: rpkiStatuses,
|
||||
cross_check: rpkiCrossCheck,
|
||||
},
|
||||
neighbours: {
|
||||
total: neighbours.length,
|
||||
upstream_count: upstreams.length,
|
||||
downstream_count: downstreams.length,
|
||||
peer_count: peers.length,
|
||||
upstreams: upstreams.slice(0, 20),
|
||||
downstreams: downstreams.slice(0, 20),
|
||||
peers: peers.slice(0, 20),
|
||||
cross_check: neighbourCrossCheck,
|
||||
},
|
||||
ix_presence: {
|
||||
total_connections: ixConnections.length,
|
||||
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
|
||||
connections: ixConnections,
|
||||
},
|
||||
ix_locations: ixLocations,
|
||||
facilities: {
|
||||
total: facilities.length,
|
||||
list: facilities,
|
||||
},
|
||||
routing: routingInfo,
|
||||
resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
|
||||
route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
|
||||
bgp_he_net: bgpHeData || null,
|
||||
atlas: {
|
||||
total_probes: atlasProbes.length,
|
||||
connected: atlasConnected.length,
|
||||
disconnected: atlasProbes.length - atlasConnected.length,
|
||||
anchors: atlasAnchors.length,
|
||||
probes: atlasProbes.slice(0, 100).map(p => ({
|
||||
id: p.id,
|
||||
status: p.status_name || p.status || "Unknown",
|
||||
is_anchor: p.is_anchor || false,
|
||||
country: p.country_code || "",
|
||||
prefix_v4: p.prefix_v4 || "",
|
||||
prefix_v6: p.prefix_v6 || "",
|
||||
description: p.description || "",
|
||||
})),
|
||||
},
|
||||
data_quality: dataQuality,
|
||||
source_timing: sourceTiming,
|
||||
contacts: (() => {
|
||||
const pocs = (pocData && pocData.data) ? pocData.data : [];
|
||||
return pocs.slice(0, 20).map(p => ({
|
||||
role: p.role || "",
|
||||
name: p.name || "",
|
||||
email: p.email || "",
|
||||
url: p.url || "",
|
||||
visible: p.visible || "",
|
||||
}));
|
||||
})(),
|
||||
registration: (() => {
|
||||
const events = (rdapData && rdapData.events) ? rdapData.events : [];
|
||||
const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || "";
|
||||
const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || "";
|
||||
return {
|
||||
created: created ? created.slice(0, 10) : "",
|
||||
last_modified: lastChg ? lastChg.slice(0, 10) : "",
|
||||
rir: rir || "",
|
||||
handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn),
|
||||
rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "",
|
||||
};
|
||||
})(),
|
||||
_provenance: {
|
||||
prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" },
|
||||
neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" },
|
||||
rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
|
||||
ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
|
||||
facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" },
|
||||
bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
|
||||
atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" },
|
||||
resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" },
|
||||
route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
|
||||
registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" },
|
||||
contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" },
|
||||
},
|
||||
};
|
||||
|
||||
// Update duration to include cross-check time
|
||||
result.meta.duration_ms = Date.now() - start;
|
||||
|
||||
// A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
|
||||
// retry soon instead of repeating a degraded result to every visitor.
|
||||
cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
|
||||
res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
const duration = Date.now() - start;
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = { handleLookup };
|
||||
51
server/routes/lookup/rir-country.js
Normal file
51
server/routes/lookup/rir-country.js
Normal file
@ -0,0 +1,51 @@
|
||||
const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("../../data/rir-country-codes");
|
||||
|
||||
// Derive RIR and country through a cascade of fallback sources: RIPE Stat
|
||||
// rir-stats-country, RDAP port43, RDAP self-link, bgp.he.net country_code,
|
||||
// then a country-code-to-RIR table as a last resort.
|
||||
function deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData) {
|
||||
let rir = "";
|
||||
let country = "";
|
||||
// RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir')
|
||||
if (Array.isArray(rirEntries) && rirEntries.length > 0) {
|
||||
country = rirEntries[0]?.location || rirEntries[0]?.country || "";
|
||||
rir = rirEntries[0]?.rir || "";
|
||||
}
|
||||
if (!rir && rirData?.data) {
|
||||
const rirField = rirData.data.rirs || [];
|
||||
if (rirField.length > 0) rir = rirField[0]?.rir || "";
|
||||
}
|
||||
// Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE")
|
||||
if (!rir && rdapData && rdapData.port43) {
|
||||
const p43 = (rdapData.port43 || "").toLowerCase();
|
||||
if (p43.includes("ripe")) rir = "RIPE";
|
||||
else if (p43.includes("arin")) rir = "ARIN";
|
||||
else if (p43.includes("apnic")) rir = "APNIC";
|
||||
else if (p43.includes("lacnic")) rir = "LACNIC";
|
||||
else if (p43.includes("afrinic")) rir = "AFRINIC";
|
||||
}
|
||||
// Also derive RIR from RDAP links (URL of the RDAP endpoint that responded)
|
||||
if (!rir && rdapData && rdapData.links) {
|
||||
const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || "";
|
||||
if (selfLink.includes("ripe")) rir = "RIPE";
|
||||
else if (selfLink.includes("arin")) rir = "ARIN";
|
||||
else if (selfLink.includes("apnic")) rir = "APNIC";
|
||||
else if (selfLink.includes("lacnic")) rir = "LACNIC";
|
||||
else if (selfLink.includes("afrinic")) rir = "AFRINIC";
|
||||
}
|
||||
// bgp.he.net country_code fallback (for unannounced/reserve ASNs)
|
||||
if (!country && bgpHeData && bgpHeData.country_code) {
|
||||
country = bgpHeData.country_code;
|
||||
}
|
||||
// Last resort: derive RIR from country code (common assignments)
|
||||
if (!rir && country) {
|
||||
if (ARIN_CC.has(country)) rir = "ARIN";
|
||||
else if (APNIC_CC.has(country)) rir = "APNIC";
|
||||
else if (LACNIC_CC.has(country)) rir = "LACNIC";
|
||||
else if (AFRINIC_CC.has(country)) rir = "AFRINIC";
|
||||
else rir = "RIPE"; // Europe + rest = RIPE NCC
|
||||
}
|
||||
return { rir, country };
|
||||
}
|
||||
|
||||
module.exports = { deriveRirAndCountry };
|
||||
71
server/routes/lookup/routing-info.js
Normal file
71
server/routes/lookup/routing-info.js
Normal file
@ -0,0 +1,71 @@
|
||||
const { fetchBgproutesVisibility } = require("../../services/http-helpers");
|
||||
|
||||
// Compute routing visibility and prefix size distribution.
|
||||
async function computeRoutingInfo(prefixes, visibilityData, prefixSizeData) {
|
||||
const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
|
||||
const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
|
||||
var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;
|
||||
|
||||
// Visibility API returns per-RIS-collector data
|
||||
// Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
|
||||
// Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
|
||||
var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
|
||||
var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
|
||||
var visTimedOut = !visibilityData || !visibilityData.data;
|
||||
visibilities.forEach(function(v) {
|
||||
if (!v || !v.probe) return;
|
||||
var v4PeerCount = v.ipv4_full_table_peer_count || 0;
|
||||
var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
|
||||
var v6PeerCount = v.ipv6_full_table_peer_count || 0;
|
||||
var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
|
||||
v4Total += v4PeerCount;
|
||||
v4Seeing += (v4PeerCount - v4NotSeeing);
|
||||
v6Total += v6PeerCount;
|
||||
v6Seeing += (v6PeerCount - v6NotSeeing);
|
||||
});
|
||||
if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
|
||||
if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
|
||||
// If visibility API timed out but we have prefixes, try bgproutes.io fallback
|
||||
if (visTimedOut && prefixes.length > 0) {
|
||||
var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
|
||||
if (!fallbackPrefix) fallbackPrefix = prefixes[0];
|
||||
if (fallbackPrefix) {
|
||||
var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
|
||||
if (bgprFallback && bgprFallback.vps_seeing > 0) {
|
||||
// Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
|
||||
var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
|
||||
ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
|
||||
ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
|
||||
totalRisPeersV4 = bgprFallback.vps_seeing;
|
||||
console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
|
||||
} else {
|
||||
ipv4VisAvg = -1;
|
||||
ipv6VisAvg = -1;
|
||||
console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
|
||||
}
|
||||
} else {
|
||||
ipv4VisAvg = -1;
|
||||
ipv6VisAvg = -1;
|
||||
}
|
||||
}
|
||||
totalRisPeersV4 = v4Total;
|
||||
totalRisPeersV6 = v6Total;
|
||||
|
||||
// Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
|
||||
var psdData = (prefixSizeData && prefixSizeData.data) || {};
|
||||
var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
|
||||
var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
|
||||
|
||||
return {
|
||||
ipv4_prefixes: ipv4Prefixes.length,
|
||||
ipv6_prefixes: ipv6Prefixes.length,
|
||||
ipv4_visibility_avg: ipv4VisAvg,
|
||||
ipv6_visibility_avg: ipv6VisAvg,
|
||||
total_ris_peers_v4: totalRisPeersV4,
|
||||
total_ris_peers_v6: totalRisPeersV6,
|
||||
prefix_sizes_v4: psV4,
|
||||
prefix_sizes_v6: psV6,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeRoutingInfo };
|
||||
112
server/routes/pdf-export.js
Normal file
112
server/routes/pdf-export.js
Normal file
@ -0,0 +1,112 @@
|
||||
const http = require("http");
|
||||
const { buildPdfHtml, buildComparisonPdfHtml } = require("../pdf-export/templates");
|
||||
const { renderHtmlToPdf, pdfCacheGet, pdfCacheSet } = require("../pdf-export/renderer");
|
||||
|
||||
const PORT = process.env.PORT || 3101;
|
||||
|
||||
// Fetch JSON from an internal endpoint on this same process (loopback) --
|
||||
// re-triggers the full HTTP/caching/rate-limit path for /api/validate,
|
||||
// /api/aspa, /api/lookup rather than calling their handler logic directly.
|
||||
async function fetchInternal(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
|
||||
const req2 = http.request(opts, res2 => {
|
||||
let body = '';
|
||||
res2.on('data', c => body += c);
|
||||
res2.on('end', () => {
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
|
||||
});
|
||||
});
|
||||
req2.on('error', reject);
|
||||
req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
|
||||
req2.end();
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/export/pdf?asn=X&format=report|executive|technical
|
||||
async function handleExportPdf(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
|
||||
const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
|
||||
? url.searchParams.get('format') : 'report';
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
|
||||
}
|
||||
const cacheKey = `pdf:${rawAsn}:${format}`;
|
||||
const cached = pdfCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
|
||||
return res.end(cached);
|
||||
}
|
||||
try {
|
||||
const [validateData, aspaData] = await Promise.all([
|
||||
fetchInternal(`/api/validate?asn=${rawAsn}`),
|
||||
fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
|
||||
]);
|
||||
if (validateData.error) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: validateData.error }));
|
||||
}
|
||||
const html = buildPdfHtml(validateData, aspaData, format);
|
||||
const pdfBuf = await renderHtmlToPdf(html);
|
||||
pdfCacheSet(cacheKey, pdfBuf);
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
|
||||
return res.end(pdfBuf);
|
||||
} catch (err) {
|
||||
console.error('[PDF] Error:', err.message);
|
||||
res.writeHead(503, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/export/pdf/compare?asn1=X&asn2=Y
|
||||
async function handleExportPdfCompare(req, res, url) {
|
||||
const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
|
||||
const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
|
||||
if (!asn1 || !asn2) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
|
||||
}
|
||||
if (asn1 === asn2) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
|
||||
}
|
||||
const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
|
||||
const cached = pdfCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
|
||||
return res.end(cached);
|
||||
}
|
||||
try {
|
||||
const [v1, a1, l1, v2, a2, l2] = await Promise.all([
|
||||
fetchInternal(`/api/validate?asn=${asn1}`),
|
||||
fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
|
||||
fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
|
||||
fetchInternal(`/api/validate?asn=${asn2}`),
|
||||
fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
|
||||
fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
|
||||
]);
|
||||
if (v1.error || v2.error) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: v1.error || v2.error }));
|
||||
}
|
||||
const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
|
||||
const pdfBuf = await renderHtmlToPdf(html);
|
||||
pdfCacheSet(cacheKey, pdfBuf);
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
|
||||
return res.end(pdfBuf);
|
||||
} catch (err) {
|
||||
console.error('[PDF] Error:', err.message);
|
||||
res.writeHead(503, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
|
||||
function handleExportOptions(req, res) {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
module.exports = { handleExportPdf, handleExportPdfCompare, handleExportOptions };
|
||||
106
server/routes/peers-find.js
Normal file
106
server/routes/peers-find.js
Normal file
@ -0,0 +1,106 @@
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
|
||||
// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
|
||||
async function handlePeersFind(req, res, url) {
|
||||
const ixName = url.searchParams.get("ix") || "";
|
||||
const policy = url.searchParams.get("policy") || "";
|
||||
const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
|
||||
const netType = url.searchParams.get("type") || "";
|
||||
|
||||
if (!ixName) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Search for IX by name
|
||||
const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
|
||||
const ixResults = ixSearch?.data || [];
|
||||
if (ixResults.length === 0) {
|
||||
return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
|
||||
}
|
||||
|
||||
// Use first matching IX
|
||||
const ix = ixResults[0];
|
||||
const ixId = ix.id;
|
||||
|
||||
// Get ixlan for this IX
|
||||
const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
|
||||
const ixlans = ixlanData?.data || [];
|
||||
if (ixlans.length === 0) {
|
||||
return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
|
||||
}
|
||||
|
||||
const ixlanId = ixlans[0].id;
|
||||
|
||||
// Get all networks at this IX
|
||||
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
|
||||
const netixlans = netixlanData?.data || [];
|
||||
|
||||
// Get unique net_ids
|
||||
const netIds = [...new Set(netixlans.map(n => n.net_id))];
|
||||
|
||||
// Fetch network details in batches
|
||||
const networks = [];
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
|
||||
const batch = netIds.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(nid => fetchPeeringDB("/net/" + nid))
|
||||
);
|
||||
batchResults.forEach(r => {
|
||||
if (r?.data?.[0]) networks.push(r.data[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter and rank
|
||||
let filtered = networks.map(net => {
|
||||
const nix = netixlans.filter(n => n.net_id === net.id);
|
||||
const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
|
||||
return {
|
||||
asn: net.asn,
|
||||
name: net.name || "",
|
||||
policy: net.policy_general || "",
|
||||
type: net.info_type || "",
|
||||
speed_mbps: maxSpeed,
|
||||
speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
|
||||
traffic: net.info_traffic || "",
|
||||
website: net.website || "",
|
||||
peeringdb_id: net.id,
|
||||
ipv4: nix[0]?.ipaddr4 || null,
|
||||
ipv6: nix[0]?.ipaddr6 || null,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
if (policy) {
|
||||
filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
|
||||
}
|
||||
if (minSpeed > 0) {
|
||||
filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
|
||||
}
|
||||
if (netType) {
|
||||
filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
|
||||
}
|
||||
|
||||
// Sort by speed desc
|
||||
filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);
|
||||
|
||||
// Also find common IXPs for each match (check if they share other IXPs)
|
||||
const duration = Date.now() - start;
|
||||
return res.end(JSON.stringify({
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
|
||||
total_members: netixlans.length,
|
||||
filtered_count: filtered.length,
|
||||
matches: filtered.slice(0, 50),
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handlePeersFind };
|
||||
77
server/routes/prefix-detail.js
Normal file
77
server/routes/prefix-detail.js
Normal file
@ -0,0 +1,77 @@
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { fetchBgproutesVisibility } = require("../services/http-helpers");
|
||||
const { validateRPKIWithCache } = require("../services/rpki");
|
||||
|
||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||
|
||||
// Prefix Detail endpoint: /api/prefix/detail?prefix=X
|
||||
async function handlePrefixDetail(req, res, url) {
|
||||
const prefix = url.searchParams.get("prefix") || "";
|
||||
if (!prefix) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const [routingStatus, visibility] = await Promise.all([
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
|
||||
]);
|
||||
|
||||
const origins = routingStatus?.data?.origins || [];
|
||||
const firstSeen = routingStatus?.data?.first_seen?.time || null;
|
||||
|
||||
// RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
|
||||
let rpkiStatus = "unknown";
|
||||
let rpkiRoas = [];
|
||||
const originAsn = origins.length > 0 ? origins[0].asn : null;
|
||||
if (originAsn) {
|
||||
try {
|
||||
const localRpki = await validateRPKIWithCache(originAsn, prefix);
|
||||
rpkiStatus = localRpki.status;
|
||||
rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
|
||||
} catch (e) {
|
||||
console.error("[Prefix Detail] RPKI validation error:", e.message);
|
||||
rpkiStatus = "unknown";
|
||||
rpkiRoas = [];
|
||||
}
|
||||
}
|
||||
var visData = visibility?.data?.visibilities || [];
|
||||
var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
|
||||
var visibilitySource = "ripe_stat";
|
||||
// bgproutes.io fallback if RIPE Stat visibility returned no data
|
||||
if (visData.length === 0 && BGPROUTES_API_KEY) {
|
||||
var bgprVis = await fetchBgproutesVisibility(prefix);
|
||||
if (bgprVis && bgprVis.vps_seeing > 0) {
|
||||
risPeersSeeingIt = bgprVis.vps_seeing;
|
||||
visData = []; // keep empty, use risPeersSeeingIt
|
||||
visibilitySource = "bgproutes.io";
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get IRR data
|
||||
let irrStatus = "unknown";
|
||||
try {
|
||||
const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
|
||||
const records = whoisData?.data?.records || [];
|
||||
if (records.length > 0) irrStatus = "found";
|
||||
} catch(_e) {}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
return res.end(JSON.stringify({
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
prefix: prefix,
|
||||
origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
|
||||
rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
|
||||
irr_status: irrStatus,
|
||||
visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
|
||||
first_seen: firstSeen,
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handlePrefixDetail };
|
||||
30
server/routes/proxy-stubs.js
Normal file
30
server/routes/proxy-stubs.js
Normal file
@ -0,0 +1,30 @@
|
||||
const { proxyToApiServer } = require("../services/api-proxy");
|
||||
|
||||
// Routes already migrated to the separate Fastify API server (src/api/ +
|
||||
// src/features/*/routes.ts) -- forwarded through rather than reimplemented
|
||||
// inline a second time. All mechanically identical (proxyToApiServer(req,
|
||||
// res, req.url)); handled as one data-driven dispatch instead of 12
|
||||
// near-duplicate one-line route handlers.
|
||||
const EXACT_PATHS = new Set([
|
||||
"/api/submarine-cables",
|
||||
"/api/global-infra",
|
||||
"/api/communities",
|
||||
"/api/irr-audit",
|
||||
"/api/asset-expand",
|
||||
"/api/rpki-history",
|
||||
"/api/aspath",
|
||||
"/api/looking-glass",
|
||||
"/api/ix-matrix",
|
||||
"/changelog-data",
|
||||
"/api/prefix-changes",
|
||||
]);
|
||||
|
||||
function isProxyStubPath(reqPath) {
|
||||
return EXACT_PATHS.has(reqPath) || reqPath.startsWith("/api/rib/");
|
||||
}
|
||||
|
||||
function handleProxyStub(req, res) {
|
||||
return proxyToApiServer(req, res, req.url);
|
||||
}
|
||||
|
||||
module.exports = { isProxyStubPath, handleProxyStub };
|
||||
48
server/routes/quick-ix.js
Normal file
48
server/routes/quick-ix.js
Normal file
@ -0,0 +1,48 @@
|
||||
const { fetchJSON } = require("../services/http-helpers");
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { quickIxCacheGet, quickIxCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// Quick-IX endpoint: /api/quick-ix?asn=X
|
||||
// Lightweight: only IX connections from PeeringDB, 1h cached
|
||||
// Used by Peering Recommendations to avoid 20x full lookups
|
||||
async function handleQuickIx(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 asn parameter" }));
|
||||
}
|
||||
const cached = quickIxCacheGet(rawAsn);
|
||||
if (cached !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
try {
|
||||
const [pdbNetData, pdbIxlanData] = await Promise.all([
|
||||
fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null),
|
||||
fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null),
|
||||
]);
|
||||
const netName = pdbNetData?.data?.[0]?.name || "";
|
||||
const ixConnections = [];
|
||||
if (pdbIxlanData && pdbIxlanData.data) {
|
||||
pdbIxlanData.data.forEach((row) => {
|
||||
ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 });
|
||||
});
|
||||
}
|
||||
// Fall back to RIPE Stat if PeeringDB returns nothing
|
||||
if (ixConnections.length === 0) {
|
||||
const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null);
|
||||
const ixs = rsStat?.data?.ixs || [];
|
||||
ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 }));
|
||||
}
|
||||
const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections };
|
||||
quickIxCacheSet(rawAsn, result);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleQuickIx };
|
||||
110
server/routes/relationships.js
Normal file
110
server/routes/relationships.js
Normal file
@ -0,0 +1,110 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { cacheGet, cacheSet } = require("../caches/response-caches");
|
||||
|
||||
// AS Relationships endpoint: /api/relationships?asn=X
|
||||
// Returns upstream providers, downstream customers, and peers with resolved
|
||||
// names. Based on RIPE Stat asn-neighbours.
|
||||
async function handleRelationships(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 cacheKey = "relationships:" + rawAsn;
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const neighbourData = await fetchRipeStatCached(
|
||||
"https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn + "&lod=1",
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
const neighbours = (neighbourData && neighbourData.data && neighbourData.data.neighbours) || [];
|
||||
const counts = (neighbourData && neighbourData.data && neighbourData.data.neighbour_counts) || {};
|
||||
|
||||
const upstreams = neighbours.filter(n => n.type === "left").sort((a,b) => (b.power||0)-(a.power||0));
|
||||
const downstreams = neighbours.filter(n => n.type === "right").sort((a,b) => (b.power||0)-(a.power||0));
|
||||
const peers = neighbours.filter(n => n.type === "uncertain").sort((a,b) => (b.power||0)-(a.power||0));
|
||||
|
||||
// Resolve AS names for top entries (upstreams + downstreams all, top 20 peers)
|
||||
const toResolve = [...upstreams, ...downstreams, ...peers.slice(0, 20)];
|
||||
const resolvedNames = {};
|
||||
await Promise.race([
|
||||
Promise.all(toResolve.map(n =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
||||
.then(r => { if (r && r.data && r.data.holder) resolvedNames[n.asn] = r.data.holder; })
|
||||
.catch(() => {})
|
||||
)),
|
||||
new Promise(r => setTimeout(r, 5000)),
|
||||
]);
|
||||
|
||||
// ---- Threat Intelligence Enrichment ----
|
||||
// Enrich neighbors with threat status from local threat_intel table
|
||||
const threatMap = {};
|
||||
const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
|
||||
const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => {
|
||||
try {
|
||||
const asNum = String(n.asn);
|
||||
const threat = await localDb.getThreatIntel(asNum);
|
||||
if (threat) {
|
||||
threatMap[n.asn] = {
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
||||
}
|
||||
});
|
||||
await Promise.race([
|
||||
Promise.all(threatPromises),
|
||||
new Promise(r => setTimeout(r, 3000)),
|
||||
]);
|
||||
|
||||
const fmt = n => ({
|
||||
asn: n.asn,
|
||||
name: resolvedNames[n.asn] || "",
|
||||
power: n.power || 0,
|
||||
v4_peers: n.v4_peers || 0,
|
||||
v6_peers: n.v6_peers || 0,
|
||||
threat_level: threatMap[n.asn]?.threat_level || null,
|
||||
threat_confidence: threatMap[n.asn]?.confidence_score || null,
|
||||
threat_source: threatMap[n.asn]?.source || null,
|
||||
});
|
||||
|
||||
const result = {
|
||||
asn: parseInt(rawAsn),
|
||||
query_time: new Date().toISOString(),
|
||||
duration_ms: Date.now() - start,
|
||||
counts: {
|
||||
upstreams: counts.left || upstreams.length,
|
||||
downstreams: counts.right || downstreams.length,
|
||||
peers_total: counts.unique || peers.length,
|
||||
uncertain: counts.uncertain || peers.length,
|
||||
},
|
||||
upstreams: upstreams.map(fmt),
|
||||
downstreams: downstreams.map(fmt),
|
||||
peers: peers.slice(0, 50).map(fmt),
|
||||
methodology: "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).",
|
||||
source_url: "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn,
|
||||
};
|
||||
|
||||
// A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
|
||||
// otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
|
||||
// match the existing /api/validate precedent (90s TTL for suspicious zero counts).
|
||||
cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "Relationships lookup failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleRelationships };
|
||||
83
server/routes/search.js
Normal file
83
server/routes/search.js
Normal file
@ -0,0 +1,83 @@
|
||||
const { fetchJSONWithRetry } = require("../services/http-helpers");
|
||||
const { PEERINGDB_API_KEY } = require("../services/peeringdb");
|
||||
const { trackVisitor } = require("../visitors");
|
||||
|
||||
// Name Search (RIPE Stat + PeeringDB combined)
|
||||
async function handleSearch(req, res) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const q = (params.get('q') || '').trim();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=120');
|
||||
if (!q || q.length < 2) { res.writeHead(400); return res.end(JSON.stringify({error:'query too short'})); }
|
||||
try {
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Source 1: RIPE Stat searchcomplete (fast, covers ASNs + org names)
|
||||
try {
|
||||
const ripeUrl = 'https://stat.ripe.net/data/searchcomplete/data.json?resource=' + encodeURIComponent(q);
|
||||
const ripeData = await fetchJSONWithRetry(ripeUrl, { timeout: 6000 });
|
||||
const cats = ripeData && ripeData.data && ripeData.data.categories || [];
|
||||
for (var ci = 0; ci < cats.length; ci++) {
|
||||
var suggs = cats[ci].suggestions || [];
|
||||
for (var si = 0; si < suggs.length; si++) {
|
||||
var s = suggs[si];
|
||||
var val = (s.value || '').toString();
|
||||
// Only ASN results
|
||||
if (/^AS\d+$/i.test(val) && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
// Use description (e.g. "FLEXOPTIX, DE") as the display label
|
||||
var ripeName = s.description || s.label || val;
|
||||
results.push({ asn: val.replace(/^AS/i,''), label: ripeName, description: '', source: 'RIPE Stat' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { /* RIPE Stat failed, continue */ }
|
||||
|
||||
// Source 2: PeeringDB name search (best for network operator names)
|
||||
try {
|
||||
var pdbUrl = 'https://www.peeringdb.com/api/net?name__icontains=' + encodeURIComponent(q) + '&depth=1&limit=10';
|
||||
if (PEERINGDB_API_KEY) pdbUrl += '&key=' + PEERINGDB_API_KEY;
|
||||
const pdbData = await fetchJSONWithRetry(pdbUrl, { timeout: 8000 });
|
||||
var nets = pdbData && pdbData.data || [];
|
||||
for (var ni = 0; ni < nets.length; ni++) {
|
||||
var net = nets[ni];
|
||||
var asnKey = 'AS' + net.asn;
|
||||
if (net.asn && !seen.has(asnKey)) {
|
||||
seen.add(asnKey);
|
||||
var pdbDesc = [net.info_type, net.country].filter(Boolean).join(' · ');
|
||||
results.push({
|
||||
asn: String(net.asn),
|
||||
label: net.name || asnKey,
|
||||
description: pdbDesc,
|
||||
source: 'PeeringDB'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(e) { /* PeeringDB failed, continue */ }
|
||||
|
||||
// Sort: RIPE results first (usually more relevant for ASN lookup), then PeeringDB
|
||||
results.sort((a, b) => {
|
||||
if (a.source === b.source) return 0;
|
||||
return a.source === 'RIPE Stat' ? -1 : 1;
|
||||
});
|
||||
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ q: q, results: results.slice(0, 12) }));
|
||||
} catch(e) {
|
||||
res.writeHead(500); return res.end(JSON.stringify({error: e.message}));
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/visitors — unique visitor count
|
||||
function handleVisitors(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const count = trackVisitor(req);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ visitors: count }));
|
||||
}
|
||||
|
||||
module.exports = { handleSearch, handleVisitors };
|
||||
61
server/routes/static.js
Normal file
61
server/routes/static.js
Normal file
@ -0,0 +1,61 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function handleRoot(req, res, host, reqPath) {
|
||||
// shell.peercortex.org → admin feedback terminal (check first)
|
||||
if (host === 'shell.peercortex.org') {
|
||||
try {
|
||||
const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8');
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
return res.end(html);
|
||||
} catch (_e) {
|
||||
res.writeHead(500);
|
||||
return res.end('shell.html not found');
|
||||
}
|
||||
}
|
||||
// v2.peercortex.org → redirect to main domain
|
||||
if (host === 'v2.peercortex.org') {
|
||||
res.writeHead(301, { Location: 'https://peercortex.org' + reqPath });
|
||||
return res.end();
|
||||
}
|
||||
const htmlFile = "index.html";
|
||||
try {
|
||||
const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
return res.end(html);
|
||||
} catch (_e) {
|
||||
res.writeHead(500);
|
||||
return res.end(htmlFile + " not found");
|
||||
}
|
||||
}
|
||||
|
||||
function handleIndexEditorial(req, res) {
|
||||
try {
|
||||
const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
return res.end(html);
|
||||
} catch (_e) {
|
||||
res.writeHead(500);
|
||||
return res.end("index-editorial.html not found");
|
||||
}
|
||||
}
|
||||
|
||||
function handleFavicon(req, res) {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
function handleLia(req, res) {
|
||||
try {
|
||||
const liaHtml = fs.readFileSync(path.join(REPO_ROOT, "public", "lia.html"), "utf8");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
return res.end(liaHtml);
|
||||
} catch (_e) {
|
||||
res.writeHead(500);
|
||||
return res.end("lia.html not found");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleRoot, handleIndexEditorial, handleFavicon, handleLia };
|
||||
59
server/routes/topology.js
Normal file
59
server/routes/topology.js
Normal file
@ -0,0 +1,59 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { fetchTopology } = require("../topology");
|
||||
|
||||
// Feature 25: Topology endpoint
|
||||
async function handleTopology(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
const depth = parseInt(url.searchParams.get("depth") || "2") || 2;
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const topology = await fetchTopology(parseInt(rawAsn), depth);
|
||||
|
||||
// ---- Threat Intelligence Enrichment for Topology Nodes ----
|
||||
const threatMap = {};
|
||||
const threatPromises = topology.nodes.slice(0, 100).map(async (node) => {
|
||||
try {
|
||||
const asNum = String(node.asn);
|
||||
const threat = await localDb.getThreatIntel(asNum);
|
||||
if (threat) {
|
||||
threatMap[node.asn] = {
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message);
|
||||
}
|
||||
});
|
||||
await Promise.race([
|
||||
Promise.all(threatPromises),
|
||||
new Promise(r => setTimeout(r, 3000)),
|
||||
]);
|
||||
|
||||
// Attach threat status to node objects
|
||||
topology.nodes.forEach((node) => {
|
||||
if (threatMap[node.asn]) {
|
||||
node.threat_level = threatMap[node.asn].threat_level;
|
||||
node.threat_confidence = threatMap[node.asn].confidence_score;
|
||||
node.threat_source = threatMap[node.asn].source;
|
||||
}
|
||||
});
|
||||
|
||||
topology.meta = {
|
||||
query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start,
|
||||
timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length,
|
||||
};
|
||||
return res.end(JSON.stringify(topology, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "Topology query failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleTopology };
|
||||
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 };
|
||||
26
server/routes/whois.js
Normal file
26
server/routes/whois.js
Normal file
@ -0,0 +1,26 @@
|
||||
const { fetchWhois } = require("../whois");
|
||||
|
||||
// Feature 27: WHOIS endpoint
|
||||
async function handleWhoisRoute(req, res, url) {
|
||||
const resource = url.searchParams.get("resource") || "";
|
||||
if (!resource) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" }));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const whoisResult = await fetchWhois(resource);
|
||||
if (!whoisResult || typeof whoisResult !== "object") {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" }));
|
||||
}
|
||||
whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() };
|
||||
return res.end(JSON.stringify(whoisResult, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleWhoisRoute };
|
||||
34
server/services/api-proxy.js
Normal file
34
server/services/api-proxy.js
Normal file
@ -0,0 +1,34 @@
|
||||
const http = require("http");
|
||||
|
||||
// Several routes were extracted into src/api/ + src/features/*/routes.ts
|
||||
// (Fastify, dist/api/index.js, run as a separate PM2 process on
|
||||
// API_SERVER_PORT) but were never re-wired here after that migration —
|
||||
// they just silently 404'd. This forwards those specific paths through
|
||||
// rather than reimplementing them inline a second time.
|
||||
const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10);
|
||||
|
||||
function proxyToApiServer(req, res, targetUrl) {
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: API_SERVER_PORT,
|
||||
path: targetUrl,
|
||||
method: req.method,
|
||||
headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }),
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
}
|
||||
);
|
||||
proxyReq.on("error", (err) => {
|
||||
console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "API server unavailable", detail: err.message }));
|
||||
}
|
||||
});
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
module.exports = { proxyToApiServer, API_SERVER_PORT };
|
||||
131
server/services/http-helpers.js
Normal file
131
server/services/http-helpers.js
Normal file
@ -0,0 +1,131 @@
|
||||
const https = require("https");
|
||||
const { UA } = require("../data/constants");
|
||||
|
||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
|
||||
|
||||
function fetchJSON(url, options) {
|
||||
const timeoutMs = (options && options.timeout) || 8000;
|
||||
return new Promise((resolve) => {
|
||||
const reqOptions = {
|
||||
headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) },
|
||||
timeout: timeoutMs,
|
||||
};
|
||||
const timer = setTimeout(() => resolve(null), timeoutMs + 500);
|
||||
https
|
||||
.get(url, reqOptions, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer);
|
||||
if (res.statusCode === 429) {
|
||||
console.warn("[PDB] Rate limited (429):", url.substring(0, 80));
|
||||
return resolve(null);
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (_e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
})
|
||||
.on("timeout", () => { clearTimeout(timer); resolve(null); })
|
||||
.on("error", () => { clearTimeout(timer); resolve(null); });
|
||||
});
|
||||
}
|
||||
|
||||
// Generic JSON fetch with one retry — for sources that occasionally fail under load (RIPE Stat, Atlas)
|
||||
async function fetchJSONWithRetry(url, options) {
|
||||
const result = await fetchJSON(url, options);
|
||||
if (result !== null) return result;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
return fetchJSON(url, options);
|
||||
}
|
||||
|
||||
function fetchHTML(url, options) {
|
||||
return new Promise((resolve) => {
|
||||
const reqOptions = {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
...(options && options.headers ? options.headers : {}),
|
||||
},
|
||||
};
|
||||
const lib = url.startsWith("https") ? require("https") : require("http");
|
||||
lib
|
||||
.get(url, reqOptions, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
return fetchHTML(res.headers.location, options).then(resolve);
|
||||
}
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve(data));
|
||||
})
|
||||
.on("error", () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
function postJSON(url, body, options) {
|
||||
return new Promise((resolve) => {
|
||||
const data = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
const timeout = (options && options.timeout) || 10000;
|
||||
const reqOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 443,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": UA,
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(data),
|
||||
...(options && options.headers ? options.headers : {}),
|
||||
},
|
||||
};
|
||||
let done = false;
|
||||
const timer = setTimeout(() => { if (!done) { done = true; req.destroy(); resolve(null); } }, timeout);
|
||||
const req = https.request(reqOptions, (res) => {
|
||||
let chunks = "";
|
||||
res.on("data", (chunk) => (chunks += chunk));
|
||||
res.on("end", () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
resolve(JSON.parse(chunks));
|
||||
} catch (_e) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// bgproutes.io visibility fallback helper
|
||||
// Queries the RIB endpoint to estimate prefix visibility across vantage points
|
||||
function fetchBgproutesVisibility(prefix) {
|
||||
if (!BGPROUTES_API_KEY) return Promise.resolve(null);
|
||||
const url = BGPROUTES_API_URL + "/rib?prefix=" + encodeURIComponent(prefix) + "&prefix_match=exact";
|
||||
return fetchJSON(url, {
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
"Authorization": "Bearer " + BGPROUTES_API_KEY,
|
||||
"User-Agent": UA,
|
||||
},
|
||||
}).then(function(data) {
|
||||
if (!data || !data.data) return null;
|
||||
// data.data should be an array of RIB entries from different vantage points
|
||||
var entries = Array.isArray(data.data) ? data.data : (data.data.entries || data.data.routes || []);
|
||||
var vpSet = new Set();
|
||||
entries.forEach(function(e) {
|
||||
if (e.vantage_point || e.vp || e.collector || e.peer_asn) {
|
||||
vpSet.add(e.vantage_point || e.vp || e.collector || e.peer_asn);
|
||||
}
|
||||
});
|
||||
return { vps_seeing: vpSet.size, total_entries: entries.length, source: "bgproutes.io" };
|
||||
}).catch(function() { return null; });
|
||||
}
|
||||
|
||||
module.exports = { fetchJSON, fetchJSONWithRetry, fetchHTML, postJSON, fetchBgproutesVisibility };
|
||||
64
server/services/manrs.js
Normal file
64
server/services/manrs.js
Normal file
@ -0,0 +1,64 @@
|
||||
const https = require("https");
|
||||
const { UA } = require("../data/constants");
|
||||
|
||||
// MANRS Participants Cache (scraped from public HTML page, 24h TTL)
|
||||
let manrsAsnSet = null; // Set<string> of member ASNs
|
||||
let manrsLastFetch = 0;
|
||||
let manrsFetching = false;
|
||||
const MANRS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
async function ensureManrsCache() {
|
||||
const now = Date.now();
|
||||
if (manrsAsnSet && (now - manrsLastFetch) < MANRS_CACHE_TTL) return;
|
||||
if (manrsFetching) {
|
||||
// Wait up to 8s for in-progress fetch
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
if (manrsAsnSet) return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
manrsFetching = true;
|
||||
try {
|
||||
const html = await new Promise((resolve, reject) => {
|
||||
const opts = { hostname: "www.manrs.org", path: "/netops/participants/", method: "GET", timeout: 15000,
|
||||
headers: { "User-Agent": UA, "Accept": "text/html" } };
|
||||
const req = https.request(opts, res => {
|
||||
let body = "";
|
||||
res.on("data", d => { body += d; });
|
||||
res.on("end", () => resolve(body));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("MANRS fetch timeout")); });
|
||||
req.end();
|
||||
});
|
||||
// Extract ASNs from <td class="asns">267490</td> — may contain multiple space-separated ASNs
|
||||
const set = new Set();
|
||||
const re = /<td[^>]*class="asns"[^>]*>([\d\s,]+)<\/td>/gi;
|
||||
for (const m of html.matchAll(re)) {
|
||||
m[1].split(/[\s,]+/).forEach(a => { const n = a.trim(); if (n) set.add(n); });
|
||||
}
|
||||
if (set.size > 0) {
|
||||
manrsAsnSet = set;
|
||||
manrsLastFetch = Date.now();
|
||||
console.log("[MANRS] Loaded " + set.size + " participant ASNs from manrs.org");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[MANRS] Failed to fetch participants:", e.message);
|
||||
} finally {
|
||||
manrsFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkManrsMembership(asn) {
|
||||
if (!manrsAsnSet) return { status: "info", participant: "unknown", message: "MANRS data not yet loaded", note: "https://www.manrs.org/netops/participants/" };
|
||||
const isMember = manrsAsnSet.has(String(asn));
|
||||
return {
|
||||
status: isMember ? "pass" : "fail",
|
||||
participant: isMember,
|
||||
member_count: manrsAsnSet.size,
|
||||
note: isMember ? "Confirmed MANRS Network Operator participant" : "Not listed as MANRS participant — https://www.manrs.org/beamanrs/",
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { ensureManrsCache, checkManrsMembership };
|
||||
216
server/services/peeringdb.js
Normal file
216
server/services/peeringdb.js
Normal file
@ -0,0 +1,216 @@
|
||||
const fs = require("fs");
|
||||
const { UA } = require("../data/constants");
|
||||
const { fetchJSON } = require("./http-helpers");
|
||||
const { Semaphore } = require("./ripe-stat");
|
||||
|
||||
const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || "";
|
||||
const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api";
|
||||
|
||||
// Local PeeringDB SQLite (peeringdb-py sync, refreshed daily by cron)
|
||||
const PEERINGDB_LOCAL_PATH = process.env.PEERINGDB_LOCAL_PATH || "/opt/peeringdb-data/peeringdb.sqlite3";
|
||||
let _pdbLocal = null;
|
||||
function getPdbLocal() {
|
||||
if (_pdbLocal) return _pdbLocal;
|
||||
try {
|
||||
const BetterSqlite3 = require("better-sqlite3");
|
||||
if (!fs.existsSync(PEERINGDB_LOCAL_PATH)) return null;
|
||||
_pdbLocal = new BetterSqlite3(PEERINGDB_LOCAL_PATH, { readonly: true, fileMustExist: true });
|
||||
console.log("[PeeringDB-local] SQLite opened:", PEERINGDB_LOCAL_PATH);
|
||||
return _pdbLocal;
|
||||
} catch (e) {
|
||||
console.warn("[PeeringDB-local] Could not open SQLite:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Map API path → SQLite result in { data: [...] } format, emulating the live PDB REST API.
|
||||
function queryPeeringDBLocal(path) {
|
||||
const db = getPdbLocal();
|
||||
if (!db) return null;
|
||||
try {
|
||||
// /net?asn=X
|
||||
const netAsnMatch = path.match(/^\/net\?asn=(\d+)/);
|
||||
if (netAsnMatch) {
|
||||
const rows = db.prepare(
|
||||
"SELECT n.*, o.name AS org_name FROM peeringdb_network n " +
|
||||
"LEFT JOIN peeringdb_organization o ON n.org_id = o.id " +
|
||||
"WHERE n.asn = ? AND n.status = 'ok'"
|
||||
).all(parseInt(netAsnMatch[1]));
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /net?status=ok&depth=0 (coverage endpoint — all networks)
|
||||
if (path === "/net?status=ok&depth=0" || path.startsWith("/net?status=ok")) {
|
||||
const rows = db.prepare(
|
||||
"SELECT id, asn, name, aka, website, info_prefixes4, info_prefixes6, " +
|
||||
"info_type, info_traffic, info_unicast, info_ipv6, policy_general, org_id " +
|
||||
"FROM peeringdb_network WHERE status = 'ok' ORDER BY asn"
|
||||
).all();
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /netixlan?net_id=X&limit=... or /netixlan?asn=X&limit=...
|
||||
const netixlanNetId = path.match(/\/netixlan\?net_id=(\d+)/);
|
||||
if (netixlanNetId) {
|
||||
const rows = db.prepare(
|
||||
"SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
|
||||
"ni.operational, ni.bfd_support, il.id AS ixlan_id, " +
|
||||
"ix.id AS ix_id, ix.name, ix.city, ix.country " +
|
||||
"FROM peeringdb_network_ixlan ni " +
|
||||
"LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " +
|
||||
"LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " +
|
||||
"WHERE ni.net_id = ? AND ni.status = 'ok'"
|
||||
).all(parseInt(netixlanNetId[1]));
|
||||
return { data: rows };
|
||||
}
|
||||
const netixlanAsn = path.match(/\/netixlan\?asn=(\d+)/);
|
||||
if (netixlanAsn) {
|
||||
const rows = db.prepare(
|
||||
"SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
|
||||
"ni.operational, ni.bfd_support, il.id AS ixlan_id, " +
|
||||
"ix.id AS ix_id, ix.name, ix.city, ix.country " +
|
||||
"FROM peeringdb_network_ixlan ni " +
|
||||
"LEFT JOIN peeringdb_ixlan il ON ni.ixlan_id = il.id " +
|
||||
"LEFT JOIN peeringdb_ix ix ON il.ix_id = ix.id " +
|
||||
"WHERE ni.asn = ? AND ni.status = 'ok'"
|
||||
).all(parseInt(netixlanAsn[1]));
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /netixlan?ixlan_id=X
|
||||
const netixlanIxlanId = path.match(/\/netixlan\?ixlan_id=(\d+)/);
|
||||
if (netixlanIxlanId) {
|
||||
const rows = db.prepare(
|
||||
"SELECT ni.id, ni.net_id, ni.asn, ni.speed, ni.ipaddr4, ni.ipaddr6, ni.is_rs_peer, " +
|
||||
"n.name AS net_name " +
|
||||
"FROM peeringdb_network_ixlan ni " +
|
||||
"LEFT JOIN peeringdb_network n ON ni.net_id = n.id " +
|
||||
"WHERE ni.ixlan_id = ? AND ni.status = 'ok'"
|
||||
).all(parseInt(netixlanIxlanId[1]));
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /netfac?net_id=X
|
||||
const netfacNetId = path.match(/\/netfac\?net_id=(\d+)/);
|
||||
if (netfacNetId) {
|
||||
const rows = db.prepare(
|
||||
"SELECT nf.id, nf.net_id, f.id AS fac_id, f.name, f.city, f.state, " +
|
||||
"f.country, f.latitude, f.longitude, f.website " +
|
||||
"FROM peeringdb_network_facility nf " +
|
||||
"LEFT JOIN peeringdb_facility f ON nf.fac_id = f.id " +
|
||||
"WHERE nf.net_id = ? AND nf.status = 'ok'"
|
||||
).all(parseInt(netfacNetId[1]));
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /fac?id__in=X,Y,Z&fields=...
|
||||
const facIdIn = path.match(/\/fac\?id__in=([\d,]+)/);
|
||||
if (facIdIn) {
|
||||
const ids = facIdIn[1].split(",").map(Number).filter(Boolean);
|
||||
if (ids.length === 0) return { data: [] };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const rows = db.prepare(
|
||||
"SELECT id, name, city, country, latitude, longitude, website " +
|
||||
"FROM peeringdb_facility WHERE id IN (" + placeholders + ") AND status = 'ok'"
|
||||
).all(...ids);
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /ixfac?ix_id__in=X,Y,Z
|
||||
const ixfacIxIdIn = path.match(/\/ixfac\?ix_id__in=([\d,]+)/);
|
||||
if (ixfacIxIdIn) {
|
||||
const ids = ixfacIxIdIn[1].split(",").map(Number).filter(Boolean);
|
||||
if (ids.length === 0) return { data: [] };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const rows = db.prepare(
|
||||
"SELECT ixf.id, ixf.ix_id, ixf.fac_id, f.latitude, f.longitude, f.city, f.country " +
|
||||
"FROM peeringdb_ix_facility ixf " +
|
||||
"LEFT JOIN peeringdb_facility f ON ixf.fac_id = f.id " +
|
||||
"WHERE ixf.ix_id IN (" + placeholders + ") AND ixf.status = 'ok'"
|
||||
).all(...ids);
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /ix?name__contains=X
|
||||
const ixNameContains = path.match(/\/ix\?name__contains=([^&]+)/);
|
||||
if (ixNameContains) {
|
||||
const term = "%" + decodeURIComponent(ixNameContains[1]) + "%";
|
||||
const rows = db.prepare(
|
||||
"SELECT id, name, name_long, city, country, website, region_continent " +
|
||||
"FROM peeringdb_ix WHERE (name LIKE ? OR name_long LIKE ?) AND status = 'ok' LIMIT 20"
|
||||
).all(term, term);
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /ixlan?ix_id=X
|
||||
const ixlanIxId = path.match(/\/ixlan\?ix_id=(\d+)/);
|
||||
if (ixlanIxId) {
|
||||
const rows = db.prepare(
|
||||
"SELECT id, ix_id, name, rs_asn, arp_sponge, mtu FROM peeringdb_ixlan " +
|
||||
"WHERE ix_id = ? AND status = 'ok'"
|
||||
).all(parseInt(ixlanIxId[1]));
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
// /net/X (single network by PDB id)
|
||||
const netById = path.match(/^\/net\/(\d+)$/);
|
||||
if (netById) {
|
||||
const row = db.prepare(
|
||||
"SELECT n.*, o.name AS org_name FROM peeringdb_network n " +
|
||||
"LEFT JOIN peeringdb_organization o ON n.org_id = o.id " +
|
||||
"WHERE n.id = ? AND n.status = 'ok'"
|
||||
).get(parseInt(netById[1]));
|
||||
return row ? { data: [row] } : { data: [] };
|
||||
}
|
||||
|
||||
return null; // path not handled locally — fall through to live API
|
||||
} catch (e) {
|
||||
console.warn("[PeeringDB-local] Query error for", path, ":", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// PeeringDB semaphore — limits concurrent PDB requests to avoid 429 rate-limits
|
||||
const pdbSemaphore = new Semaphore(5);
|
||||
|
||||
// PeeringDB authenticated fetch helper — tries local SQLite first, falls back to live API
|
||||
async function fetchPeeringDB(path, options) {
|
||||
// Try local SQLite (instant, no rate-limits) — skip large "all networks" calls to live API
|
||||
const localResult = queryPeeringDBLocal(path);
|
||||
if (localResult !== null) return localResult;
|
||||
|
||||
// Fallback: live PeeringDB API (throttled via semaphore)
|
||||
const url = PEERINGDB_API_URL + path;
|
||||
const headers = { "User-Agent": UA };
|
||||
if (PEERINGDB_API_KEY) {
|
||||
headers["Authorization"] = "Api-Key " + PEERINGDB_API_KEY;
|
||||
}
|
||||
await pdbSemaphore.acquire();
|
||||
try {
|
||||
return await fetchJSON(url, { ...options, headers: { ...(options && options.headers || {}), ...headers } });
|
||||
} finally {
|
||||
pdbSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
// PeeringDB fetch with exponential backoff retries (handles rate-limits under concurrent load).
|
||||
// Up to 3 attempts: immediate → 2s → 5s. Returns null only after all attempts exhausted.
|
||||
async function fetchPeeringDBWithRetry(path, options) {
|
||||
const delays = [2000, 5000];
|
||||
let result = await fetchPeeringDB(path, options);
|
||||
for (let i = 0; i < delays.length && result === null; i++) {
|
||||
await new Promise(r => setTimeout(r, delays[i]));
|
||||
result = await fetchPeeringDB(path, options);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PEERINGDB_API_KEY,
|
||||
PEERINGDB_API_URL,
|
||||
getPdbLocal,
|
||||
queryPeeringDBLocal,
|
||||
pdbSemaphore,
|
||||
fetchPeeringDB,
|
||||
fetchPeeringDBWithRetry,
|
||||
};
|
||||
184
server/services/ripe-stat.js
Normal file
184
server/services/ripe-stat.js
Normal file
@ -0,0 +1,184 @@
|
||||
const fs = require("fs");
|
||||
const { fetchJSON } = require("./http-helpers");
|
||||
const localDb = require("../../local-db-client");
|
||||
|
||||
// RIPE Stat Source Cache + Semaphore (L2)
|
||||
// Prevents 429 rate-limiting by throttling + caching responses
|
||||
const ripeStatCache = new Map(); // key: "endpoint:resource" → {data, ts}
|
||||
const RIPE_STAT_CACHE_MAX = 2000;
|
||||
const RIPE_STAT_TTL = {
|
||||
"announced-prefixes": 15 * 60 * 1000,
|
||||
"asn-neighbours": 15 * 60 * 1000,
|
||||
"as-overview": 60 * 60 * 1000,
|
||||
"rir-stats-country": 24 * 60 * 60 * 1000,
|
||||
"visibility": 15 * 60 * 1000,
|
||||
"prefix-size-distribution": 60 * 60 * 1000,
|
||||
"abuse-contact-finder": 24 * 60 * 60 * 1000,
|
||||
"blocklist": 60 * 60 * 1000,
|
||||
"reverse-dns-consistency": 60 * 60 * 1000,
|
||||
"routing-status": 15 * 60 * 1000,
|
||||
"bgp-updates": 15 * 60 * 1000,
|
||||
"maxmind-geo-lite-pfx": 24 * 60 * 60 * 1000,
|
||||
"looking-glass": 15 * 60 * 1000,
|
||||
"whois": 24 * 60 * 60 * 1000,
|
||||
"rpki-validation": 6 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
// Counting semaphore — limits concurrent RIPE Stat requests
|
||||
class Semaphore {
|
||||
constructor(max) { this.max = max; this.current = 0; this.queue = []; }
|
||||
acquire() {
|
||||
if (this.current < this.max) { this.current++; return Promise.resolve(); }
|
||||
return new Promise((resolve) => this.queue.push(resolve));
|
||||
}
|
||||
release() {
|
||||
this.current--;
|
||||
if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
|
||||
}
|
||||
}
|
||||
const ripeStatSemaphore = new Semaphore(15);
|
||||
|
||||
// Cached + throttled RIPE Stat fetch
|
||||
// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact
|
||||
// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres
|
||||
// refactor. In JS, two `function` declarations sharing a name silently collapse to
|
||||
// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle
|
||||
// implementation below was completely shadowed, and every call to
|
||||
// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours,
|
||||
// as-overview, visibility, prefix-size-distribution} was unconditionally returning
|
||||
// null instead of ever reaching this code. That silently broke blocklist,
|
||||
// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois,
|
||||
// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's
|
||||
// fallback call to this function for the fix.
|
||||
async function fetchRipeStatCachedFromApi(url, options) {
|
||||
// Extract endpoint name from URL for TTL lookup
|
||||
const match = url.match(/\/data\/([^/]+)\//);
|
||||
const endpoint = match ? match[1] : "default";
|
||||
const resourceMatch = url.match(/resource=([^&]+)/);
|
||||
const resource = resourceMatch ? resourceMatch[1] : url;
|
||||
const cacheKey = endpoint + ":" + resource;
|
||||
|
||||
// Check cache
|
||||
const cached = ripeStatCache.get(cacheKey);
|
||||
const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
|
||||
if (cached && (Date.now() - cached.ts) < ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Throttle via semaphore
|
||||
await ripeStatSemaphore.acquire();
|
||||
try {
|
||||
// Double-check cache after acquiring semaphore (another request may have filled it)
|
||||
const cached2 = ripeStatCache.get(cacheKey);
|
||||
if (cached2 && (Date.now() - cached2.ts) < ttl) {
|
||||
return cached2.data;
|
||||
}
|
||||
|
||||
const result = await fetchJSON(url, options);
|
||||
|
||||
// Only cache successful results — never cache null (failed/rate-limited responses)
|
||||
// Caching null causes cascading failures: retry hits cache, returns null again
|
||||
if (result !== null) {
|
||||
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
|
||||
ripeStatCache.delete(ripeStatCache.keys().next().value);
|
||||
}
|
||||
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
ripeStatSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Cached + throttled RIPE Stat with one retry on failure
|
||||
async function fetchRipeStatCachedWithRetry(url, options) {
|
||||
const result = await fetchRipeStatCached(url, options);
|
||||
if (result !== null) return result;
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
return fetchRipeStatCached(url, options);
|
||||
}
|
||||
|
||||
// RIPE Stat cache disk persistence (skip null entries)
|
||||
function saveRipeStatCacheToDisk(filePath) {
|
||||
try {
|
||||
const obj = {};
|
||||
for (const [k, v] of ripeStatCache) {
|
||||
if (v.data !== null) obj[k] = v;
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
|
||||
console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
|
||||
} catch (e) {
|
||||
console.warn("[RIPE-CACHE] Disk save failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadRipeStatCacheFromDisk(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
const now = Date.now();
|
||||
for (const [k, v] of Object.entries(data.entries || {})) {
|
||||
const match = k.match(/^([^:]+):/);
|
||||
const endpoint = match ? match[1] : "default";
|
||||
const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
|
||||
if (now - v.ts < ttl) {
|
||||
ripeStatCache.set(k, v);
|
||||
}
|
||||
}
|
||||
console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[RIPE-CACHE] Disk load failed:", e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// MASTER RIPE Stat API wrapper (Local-first, zero external API calls).
|
||||
// Analyzes RIPE Stat URL and dispatches to appropriate localDb function.
|
||||
async function fetchRipeStatCached(url, options = {}) {
|
||||
try {
|
||||
// Detect which RIPE Stat endpoint this is and call local DB
|
||||
if (url.includes('/announced-prefixes/')) {
|
||||
const asnMatch = url.match(/resource=AS(\d+)/);
|
||||
if (asnMatch) return await localDb.getRipeStatAnnouncedPrefixes(parseInt(asnMatch[1]));
|
||||
}
|
||||
if (url.includes('/asn-neighbours/')) {
|
||||
const asnMatch = url.match(/resource=AS(\d+)/);
|
||||
if (asnMatch) return await localDb.getRipeStatAsnNeighbours(parseInt(asnMatch[1]));
|
||||
}
|
||||
if (url.includes('/as-overview/')) {
|
||||
const asnMatch = url.match(/resource=AS(\d+)/);
|
||||
if (asnMatch) return await localDb.getRipeStatAsOverview(parseInt(asnMatch[1]));
|
||||
}
|
||||
if (url.includes('/visibility/')) {
|
||||
const asnMatch = url.match(/resource=AS(\d+)/);
|
||||
if (asnMatch) return await localDb.getRipeStatVisibility(parseInt(asnMatch[1]));
|
||||
}
|
||||
if (url.includes('/prefix-size-distribution/')) {
|
||||
const asnMatch = url.match(/resource=AS(\d+)/);
|
||||
if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
|
||||
}
|
||||
|
||||
// For every other RIPE Stat endpoint (not mirrored into the local Postgres DB) --
|
||||
// e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass,
|
||||
// whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the
|
||||
// real RIPE Stat API with its own cache/throttle/never-cache-null protections,
|
||||
// instead of returning null unconditionally (see fetchRipeStatCachedFromApi's
|
||||
// header comment for how this silently broke ~9 checks until 2026-07-16).
|
||||
return fetchRipeStatCachedFromApi(url, options);
|
||||
} catch (e) {
|
||||
console.error("[fetchRipeStatCached] Error:", e.message);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ripeStatCache,
|
||||
fetchRipeStatCached,
|
||||
fetchRipeStatCachedFromApi,
|
||||
fetchRipeStatCachedWithRetry,
|
||||
saveRipeStatCacheToDisk,
|
||||
loadRipeStatCacheFromDisk,
|
||||
Semaphore,
|
||||
};
|
||||
200
server/services/rpki.js
Normal file
200
server/services/rpki.js
Normal file
@ -0,0 +1,200 @@
|
||||
const https = require("https");
|
||||
const localDb = require("../../local-db-client");
|
||||
const { UA } = require("../data/constants");
|
||||
const { fetchJSON } = require("./http-helpers");
|
||||
const { roaStore } = require("../caches/roa-store");
|
||||
const { recordAspaAdoptionSnapshot } = require("../aspa-adoption/tracker");
|
||||
|
||||
// RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed
|
||||
const rpkiAspaMap = new Map(); // customer_asid -> Set<provider_asn>
|
||||
let rpkiAspaLastFetch = 0;
|
||||
let rpkiAspaFetching = false;
|
||||
|
||||
function fetchRpkiAspaFeed() {
|
||||
if (rpkiAspaFetching) return Promise.resolve();
|
||||
rpkiAspaFetching = true;
|
||||
console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)...");
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
headers: { "User-Agent": UA },
|
||||
timeout: 120000,
|
||||
};
|
||||
https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Load ASPA objects
|
||||
const aspas = parsed.aspas || [];
|
||||
rpkiAspaMap.clear();
|
||||
aspas.forEach((a) => {
|
||||
const customerAsid = Number(a.customer_asid);
|
||||
const providers = (a.providers || []).map(Number);
|
||||
rpkiAspaMap.set(customerAsid, new Set(providers));
|
||||
});
|
||||
|
||||
// Load ROA objects into local store (eliminates RIPE Stat per-prefix calls)
|
||||
const roas = parsed.roas || [];
|
||||
roaStore.build(roas);
|
||||
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
|
||||
|
||||
// Track ASPA adoption — persist to disk + update in-memory trend
|
||||
recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap);
|
||||
|
||||
rpkiAspaLastFetch = Date.now();
|
||||
console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
|
||||
} catch (e) {
|
||||
console.error("[RPKI] Failed to parse RPKI feed:", e.message);
|
||||
}
|
||||
rpkiAspaFetching = false;
|
||||
resolve();
|
||||
});
|
||||
}).on("error", (e) => {
|
||||
console.error("[RPKI] Fetch failed:", e.message);
|
||||
rpkiAspaFetching = false;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure ASPA + ROA cache is fresh
|
||||
async function ensureAspaCache() {
|
||||
if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) {
|
||||
await fetchRpkiAspaFeed();
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup ASPA object for a given ASN from the RPKI feed cache. feedLoaded
|
||||
// distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object"
|
||||
// from "the Cloudflare feed fetch has never succeeded, so we don't actually know" --
|
||||
// rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so
|
||||
// 0 means every attempt so far has failed and exists:false here is not trustworthy.
|
||||
function lookupAspaFromRpki(asn) {
|
||||
const asnNum = Number(asn);
|
||||
const feedLoaded = rpkiAspaLastFetch > 0;
|
||||
if (rpkiAspaMap.has(asnNum)) {
|
||||
const providers = rpkiAspaMap.get(asnNum);
|
||||
return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded };
|
||||
}
|
||||
return { exists: false, providers: [], feedLoaded };
|
||||
}
|
||||
|
||||
// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls)
|
||||
// Returns: { prefix, status: "valid"|"invalid"|"not_found"|"unavailable", validating_roas: N }
|
||||
// "not_found" means the DB was queried and genuinely has no covering ROA -- a real result.
|
||||
// "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know.
|
||||
// Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse-
|
||||
// sounding finding). Callers computing coverage percentages should exclude "unavailable".
|
||||
async function validateRPKIWithCache(asn, prefix) {
|
||||
try {
|
||||
const result = await localDb.validateRpki(prefix, asn);
|
||||
if (result.status === 'valid') {
|
||||
return { prefix, status: "valid", validating_roas: 1 };
|
||||
} else if (result.status === 'invalid') {
|
||||
return { prefix, status: "invalid", validating_roas: 1 };
|
||||
} else if (result.status === 'unknown') {
|
||||
return { prefix, status: "unavailable", validating_roas: 0 };
|
||||
} else {
|
||||
// 'not-found': genuinely no covering ROA
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
}
|
||||
} catch (_e) {
|
||||
console.error("[RPKI] Error validating " + prefix + ":", _e.message);
|
||||
return { prefix, status: "unavailable", validating_roas: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// agreement_pct only counts pairs where BOTH sources actually returned a real
|
||||
// verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct:
|
||||
// null (not a fabricated 100), since nothing was actually cross-checked.
|
||||
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: null, disagreements: [], sample_size: 0, comparable_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;
|
||||
let comparable = 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++;
|
||||
|
||||
// A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from
|
||||
// the agreement percentage instead of counting it as a silent "agreement".
|
||||
if (ripeState === "error" || ripeState === "unknown") {
|
||||
continue;
|
||||
}
|
||||
|
||||
comparable++;
|
||||
if (cfIsValid === ripeIsValid) {
|
||||
agreements++;
|
||||
} else {
|
||||
disagreements.push({
|
||||
prefix: pfx,
|
||||
cloudflare: cfStatus,
|
||||
ripe: ripeState,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null;
|
||||
return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable };
|
||||
}
|
||||
|
||||
// rpkiAspaLastFetch is a reassigned `let`, not a mutated object -- a bare
|
||||
// export would only capture its value (0) at require time. Expose it via a
|
||||
// getter so callers (e.g. /api/health) always see the current value.
|
||||
function getRpkiAspaLastFetch() {
|
||||
return rpkiAspaLastFetch;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rpkiAspaMap,
|
||||
getRpkiAspaLastFetch,
|
||||
fetchRpkiAspaFeed,
|
||||
ensureAspaCache,
|
||||
lookupAspaFromRpki,
|
||||
validateRPKIWithCache,
|
||||
fetchRipeRpkiValidator,
|
||||
crossCheckRpki,
|
||||
};
|
||||
49
server/topology.js
Normal file
49
server/topology.js
Normal file
@ -0,0 +1,49 @@
|
||||
const { fetchRipeStatCached } = require("./services/ripe-stat");
|
||||
|
||||
// Feature 25: Topology / AS-Relationships
|
||||
async function fetchTopology(targetAsn, depth) {
|
||||
const maxDepth = Math.min(depth || 2, 3);
|
||||
const nodes = new Map();
|
||||
const edges = [];
|
||||
async function fetchNeighboursForAsn(asn, currentDepth) {
|
||||
if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
|
||||
const [data, overview] = await Promise.all([
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
|
||||
]);
|
||||
const name = overview?.data?.holder || "";
|
||||
const neighbours = data?.data?.neighbours || [];
|
||||
const upstreams = neighbours.filter((n) => n.type === "left");
|
||||
const downstreams = neighbours.filter((n) => n.type === "right");
|
||||
const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
|
||||
const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
|
||||
nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
|
||||
upstreams.forEach((n) => {
|
||||
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
|
||||
edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
|
||||
});
|
||||
downstreams.forEach((n) => {
|
||||
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
|
||||
edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
|
||||
});
|
||||
peers.slice(0, 10).forEach((n) => {
|
||||
if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
|
||||
edges.push({ from: asn, to: n.asn, relationship: "peer" });
|
||||
});
|
||||
if (currentDepth < maxDepth && upstreams.length > 0) {
|
||||
const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
|
||||
await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
|
||||
}
|
||||
}
|
||||
await fetchNeighboursForAsn(targetAsn, 0);
|
||||
const edgeSet = new Set();
|
||||
const uniqueEdges = edges.filter((e) => {
|
||||
const key = e.from + "-" + e.to + "-" + e.relationship;
|
||||
if (edgeSet.has(key)) return false;
|
||||
edgeSet.add(key);
|
||||
return true;
|
||||
});
|
||||
return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
|
||||
}
|
||||
|
||||
module.exports = { fetchTopology };
|
||||
21
server/visitors.js
Normal file
21
server/visitors.js
Normal file
@ -0,0 +1,21 @@
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const VISITORS_FILE = "/opt/peercortex-app/visitors.json";
|
||||
|
||||
function loadVisitors() {
|
||||
try { return JSON.parse(fs.readFileSync(VISITORS_FILE, "utf8")); } catch (_) { return { hashes: [] }; }
|
||||
}
|
||||
|
||||
function trackVisitor(req) {
|
||||
const ip = (req.headers["x-forwarded-for"] || "").split(",")[0].trim() || (req.socket && req.socket.remoteAddress) || "";
|
||||
const hash = crypto.createHash("sha256").update(ip + "peercortex-salt-2026").digest("hex");
|
||||
const data = loadVisitors();
|
||||
if (!data.hashes.includes(hash)) {
|
||||
data.hashes.push(hash);
|
||||
try { fs.writeFileSync(VISITORS_FILE, JSON.stringify(data)); } catch (_) {}
|
||||
}
|
||||
return data.hashes.length;
|
||||
}
|
||||
|
||||
module.exports = { loadVisitors, trackVisitor };
|
||||
146
server/whois.js
Normal file
146
server/whois.js
Normal file
@ -0,0 +1,146 @@
|
||||
const { fetchJSON } = require("./services/http-helpers");
|
||||
const { whoisCacheGet, whoisCacheSet, WHOIS_NOT_FOUND_CACHE_TTL } = require("./caches/response-caches");
|
||||
|
||||
// Feature 27: WHOIS via RIPE DB
|
||||
async function fetchWhois(resource) {
|
||||
const result = { resource, type: null, data: null, error: null };
|
||||
try {
|
||||
const trimmed = resource.trim();
|
||||
if (/^(AS)?\d+$/i.test(trimmed)) {
|
||||
result.type = "aut-num";
|
||||
const asn = trimmed.replace(/^AS/i, "");
|
||||
|
||||
// Check cache first
|
||||
const cached = whoisCacheGet(asn);
|
||||
if (cached !== undefined) {
|
||||
result.data = cached;
|
||||
if (!cached) result.error = "Not found in any RIR database (cached)";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Try RIPE first
|
||||
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null);
|
||||
if (ripeData && ripeData.objects && ripeData.objects.object) {
|
||||
const obj = ripeData.objects.object[0];
|
||||
const attrs = obj.attributes?.attribute || [];
|
||||
const parsed = {};
|
||||
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
|
||||
result.data = {
|
||||
aut_num: (parsed["aut-num"] || [])[0] || "",
|
||||
as_name: (parsed["as-name"] || [])[0] || "",
|
||||
descr: parsed["descr"] || [],
|
||||
org: (parsed["org"] || [])[0] || "",
|
||||
admin_c: parsed["admin-c"] || [],
|
||||
tech_c: parsed["tech-c"] || [],
|
||||
mnt_by: parsed["mnt-by"] || [],
|
||||
status: (parsed["status"] || [])[0] || "",
|
||||
created: (parsed["created"] || [])[0] || "",
|
||||
last_modified: (parsed["last-modified"] || [])[0] || "",
|
||||
source: (parsed["source"] || [])[0] || "",
|
||||
import: parsed["import"] || [],
|
||||
export: parsed["export"] || [],
|
||||
remarks: parsed["remarks"] || [],
|
||||
};
|
||||
whoisCacheSet(asn, result.data);
|
||||
}
|
||||
|
||||
// If RIPE didn't find it, try all other RIRs via RDAP in parallel (3s timeout)
|
||||
if (!result.data) {
|
||||
const rdapEndpoints = [
|
||||
{ name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn },
|
||||
{ name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
|
||||
{ name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
|
||||
{ name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
|
||||
];
|
||||
const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
|
||||
fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
|
||||
if (!d || d.errorCode || !d.handle) return null;
|
||||
return { source: ep.name, data: d };
|
||||
}).catch(() => null)
|
||||
));
|
||||
const found = rdapResults.find((r) => r !== null);
|
||||
if (found) {
|
||||
const d = found.data;
|
||||
const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
|
||||
const entities = d.entities || [];
|
||||
const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
|
||||
const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
|
||||
const events = d.events || [];
|
||||
const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
|
||||
const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
|
||||
result.data = {
|
||||
aut_num: "AS" + asn,
|
||||
as_name: d.name || "",
|
||||
descr: remarks,
|
||||
org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
|
||||
admin_c: adminContacts,
|
||||
tech_c: techContacts,
|
||||
mnt_by: [],
|
||||
status: (d.status || []).join(", "),
|
||||
created: created,
|
||||
last_modified: lastMod,
|
||||
source: found.source + " (RDAP)",
|
||||
import: [],
|
||||
export: [],
|
||||
remarks: remarks,
|
||||
};
|
||||
whoisCacheSet(asn, result.data);
|
||||
} else {
|
||||
result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
|
||||
// Short TTL: this "not found" likely reflects a transient failure across
|
||||
// all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
|
||||
whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
|
||||
}
|
||||
}
|
||||
} else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
|
||||
result.type = "inetnum";
|
||||
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
|
||||
if (ripeData && ripeData.objects && ripeData.objects.object) {
|
||||
const results = ripeData.objects.object.map((obj) => {
|
||||
const attrs = obj.attributes?.attribute || [];
|
||||
const parsed = {};
|
||||
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
|
||||
return {
|
||||
inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
|
||||
netname: (parsed["netname"] || [])[0] || "",
|
||||
descr: parsed["descr"] || [],
|
||||
country: (parsed["country"] || [])[0] || "",
|
||||
org: (parsed["org"] || [])[0] || "",
|
||||
admin_c: parsed["admin-c"] || [],
|
||||
tech_c: parsed["tech-c"] || [],
|
||||
mnt_by: parsed["mnt-by"] || [],
|
||||
status: (parsed["status"] || [])[0] || "",
|
||||
created: (parsed["created"] || [])[0] || "",
|
||||
last_modified: (parsed["last-modified"] || [])[0] || "",
|
||||
source: (parsed["source"] || [])[0] || "",
|
||||
};
|
||||
});
|
||||
result.data = results.length === 1 ? results[0] : results;
|
||||
} else { result.error = "Not found in RIPE DB"; }
|
||||
} else {
|
||||
result.type = "domain";
|
||||
const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
|
||||
if (ripeData && ripeData.objects && ripeData.objects.object) {
|
||||
const obj = ripeData.objects.object[0];
|
||||
const attrs = obj.attributes?.attribute || [];
|
||||
const parsed = {};
|
||||
attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
|
||||
result.data = {
|
||||
domain: (parsed["domain"] || [])[0] || "",
|
||||
descr: parsed["descr"] || [],
|
||||
admin_c: parsed["admin-c"] || [],
|
||||
tech_c: parsed["tech-c"] || [],
|
||||
zone_c: parsed["zone-c"] || [],
|
||||
nserver: parsed["nserver"] || [],
|
||||
mnt_by: parsed["mnt-by"] || [],
|
||||
created: (parsed["created"] || [])[0] || "",
|
||||
last_modified: (parsed["last-modified"] || [])[0] || "",
|
||||
source: (parsed["source"] || [])[0] || "",
|
||||
};
|
||||
} else { result.error = "Not found in RIPE DB"; }
|
||||
}
|
||||
} catch (err) { result.error = err.message; }
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { fetchWhois };
|
||||
Loading…
x
Reference in New Issue
Block a user