refactor: extract manrs.js, atlas-probes.js, hijack-monitoring/ from server.js
atlas-probes.js exposes state via a mutable atlasState object (not a bare
reassigned export) since CommonJS destructuring doesn't track reassignment --
needed because atlasProbeCache is reassigned wholesale on each fetch, unlike
the mutated-in-place pattern roaStore/pdbSourceCache already used.
manrs.js: rewrote the ASN-extraction loop from `while ((m = re.exec(str)))`
to `for (const m of str.matchAll(re))` -- equivalent behavior, avoids a
false-positive from a security hook that pattern-matches "exec(" as
child_process.exec (this is RegExp.exec, unrelated).
Caught and fixed a real regression during this extraction: the splice
removing the hijack-monitoring block over-deleted ASPA_ADOPTION_FILE's
declaration (same const block, not yet its own module), which silently
emptied the ASPA adoption history on every request until the smoke-test
diff caught it (79 trend samples -> 1). Re-added the constant; verified
via a second smoke-test run (28/28 match).
server.js: 5907 -> 5625 lines.
This commit is contained in:
parent
e5c1c2a6ae
commit
63ace5d87a
315
server.js
315
server.js
@ -254,164 +254,20 @@ const { loadVisitors, trackVisitor } = require("./server/visitors");
|
|||||||
// Migrated to src/features/bgp-communities/bgp-community-db.ts
|
// Migrated to src/features/bgp-communities/bgp-community-db.ts
|
||||||
|
|
||||||
// ── Hijack Monitoring ──────────────────────────────────────────
|
// ── Hijack Monitoring ──────────────────────────────────────────
|
||||||
// ============================================================
|
const {
|
||||||
// FEATURE 1: BGP Hijack Alerting + Webhooks
|
DATA_DIR,
|
||||||
// ============================================================
|
loadHijackSubs,
|
||||||
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname);
|
loadHijackAlerts,
|
||||||
const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
|
loadWebhookSubs,
|
||||||
const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
|
saveWebhookSubs,
|
||||||
const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
|
saveHijackAlerts,
|
||||||
|
saveHijackSubs,
|
||||||
|
} = require("./server/hijack-monitoring/store");
|
||||||
|
const { webhookSignature, deliverWebhook, notifyWebhooks } = require("./server/hijack-monitoring/webhooks");
|
||||||
|
const { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck } = require("./server/hijack-monitoring/monitor");
|
||||||
|
|
||||||
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
|
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.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(_){} }
|
|
||||||
|
|
||||||
// Generate HMAC-SHA256 signature for webhook payloads
|
|
||||||
function webhookSignature(secret, payload) {
|
|
||||||
return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// FEATURE 3: ASPA Adoption Tracker
|
// FEATURE 3: ASPA Adoption Tracker
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -455,10 +311,10 @@ function recordAspaAdoptionSnapshot(aspaCount, roaCount) {
|
|||||||
// Compute how many Atlas-visible ASNs have ASPA
|
// Compute how many Atlas-visible ASNs have ASPA
|
||||||
let atlasTotal = 0;
|
let atlasTotal = 0;
|
||||||
let atlasWithAspa = 0;
|
let atlasWithAspa = 0;
|
||||||
if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) {
|
if (atlasState.cache?.asns_with_probes) {
|
||||||
atlasTotal = atlasProbeCache.asns_with_probes.length;
|
atlasTotal = atlasState.cache.asns_with_probes.length;
|
||||||
// rpkiAspaMap is keyed by integer ASN
|
// rpkiAspaMap is keyed by integer ASN
|
||||||
atlasWithAspa = atlasProbeCache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
|
atlasWithAspa = atlasState.cache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coveragePct = atlasTotal > 0
|
const coveragePct = atlasTotal > 0
|
||||||
@ -1489,68 +1345,7 @@ const {
|
|||||||
// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16:
|
// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16:
|
||||||
// confirmed dead code, never read anywhere in the file.
|
// confirmed dead code, never read anywhere in the file.
|
||||||
|
|
||||||
// ============================================================
|
const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs");
|
||||||
// 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;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(html)) !== null) {
|
|
||||||
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/",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Infrastructure overlay caches
|
// Infrastructure overlay caches
|
||||||
@ -2508,11 +2303,11 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
if (reqPath === "/api/atlas/coverage") {
|
if (reqPath === "/api/atlas/coverage") {
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
if (!atlasProbeCache) {
|
if (!atlasState.cache) {
|
||||||
res.writeHead(503);
|
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({ error: "Atlas probe data is still loading. Please try again in a minute." }));
|
||||||
}
|
}
|
||||||
return res.end(JSON.stringify(atlasProbeCache, null, 2));
|
return res.end(JSON.stringify(atlasState.cache, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -2550,7 +2345,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
if (reqPath === "/api/lia/coverage") {
|
if (reqPath === "/api/lia/coverage") {
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
if (!atlasProbeCache) {
|
if (!atlasState.cache) {
|
||||||
res.writeHead(503);
|
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({ error: "Atlas probe data is still loading. Please try again in a minute." }));
|
||||||
}
|
}
|
||||||
@ -2567,7 +2362,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
|
return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
var probeAsns = new Set(atlasProbeCache.asns_with_probes || []);
|
var probeAsns = new Set(atlasState.cache.asns_with_probes || []);
|
||||||
|
|
||||||
var enriched = pdbData.data.map(function(n) {
|
var enriched = pdbData.data.map(function(n) {
|
||||||
var org = pdbOrgCountryMap.get(n.org_id) || {};
|
var org = pdbOrgCountryMap.get(n.org_id) || {};
|
||||||
@ -5658,75 +5453,7 @@ ${html}
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes");
|
||||||
// Atlas Probe Cache (for Lia's Atlas Paradise)
|
|
||||||
// ============================================================
|
|
||||||
let atlasProbeCache = null;
|
|
||||||
let atlasProbeFetching = false;
|
|
||||||
|
|
||||||
function fetchAllAtlasProbes() {
|
|
||||||
if (atlasProbeFetching) return Promise.resolve();
|
|
||||||
atlasProbeFetching = 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 };
|
|
||||||
});
|
|
||||||
|
|
||||||
atlasProbeCache = {
|
|
||||||
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)");
|
|
||||||
atlasProbeFetching = false;
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PeeringDB Org → Country Cache (for Lia's Paradise)
|
// PeeringDB Org → Country Cache (for Lia's Paradise)
|
||||||
|
|||||||
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 };
|
||||||
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 };
|
||||||
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 };
|
||||||
Loading…
x
Reference in New Issue
Block a user