From 63ace5d87ae9caa9a72f40cd584fae1b00346197 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Thu, 16 Jul 2026 23:41:23 +0200 Subject: [PATCH] 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. --- server.js | 315 ++------------------------- server/atlas-probes.js | 77 +++++++ server/hijack-monitoring/monitor.js | 72 ++++++ server/hijack-monitoring/store.js | 31 +++ server/hijack-monitoring/webhooks.js | 82 +++++++ server/services/manrs.js | 64 ++++++ 6 files changed, 347 insertions(+), 294 deletions(-) create mode 100644 server/atlas-probes.js create mode 100644 server/hijack-monitoring/monitor.js create mode 100644 server/hijack-monitoring/store.js create mode 100644 server/hijack-monitoring/webhooks.js create mode 100644 server/services/manrs.js diff --git a/server.js b/server.js index 6eaa42d..9b7a732 100644 --- a/server.js +++ b/server.js @@ -254,163 +254,19 @@ const { loadVisitors, trackVisitor } = require("./server/visitors"); // Migrated to src/features/bgp-communities/bgp-community-db.ts // ── Hijack Monitoring ────────────────────────────────────────── -// ============================================================ -// FEATURE 1: BGP Hijack Alerting + Webhooks -// ============================================================ -const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname); -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'; -const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json'; +const { + DATA_DIR, + loadHijackSubs, + loadHijackAlerts, + loadWebhookSubs, + saveWebhookSubs, + 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"); -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); +const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json'; // ============================================================ // FEATURE 3: ASPA Adoption Tracker @@ -455,10 +311,10 @@ function recordAspaAdoptionSnapshot(aspaCount, roaCount) { // Compute how many Atlas-visible ASNs have ASPA let atlasTotal = 0; let atlasWithAspa = 0; - if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) { - atlasTotal = atlasProbeCache.asns_with_probes.length; + if (atlasState.cache?.asns_with_probes) { + atlasTotal = atlasState.cache.asns_with_probes.length; // 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 @@ -1489,68 +1345,7 @@ const { // bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16: // confirmed dead code, never read anywhere in the file. -// ============================================================ -// MANRS Participants Cache (scraped from public HTML page, 24h TTL) -// ============================================================ -let manrsAsnSet = null; // Set 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 267490 — may contain multiple space-separated ASNs - const set = new Set(); - const re = /]*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/", - }; -} +const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs"); // ============================================================ // Infrastructure overlay caches @@ -2508,11 +2303,11 @@ const server = http.createServer(async (req, res) => { // ============================================================ if (reqPath === "/api/atlas/coverage") { res.setHeader("Content-Type", "application/json"); - if (!atlasProbeCache) { + 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(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") { res.setHeader("Content-Type", "application/json"); - if (!atlasProbeCache) { + if (!atlasState.cache) { res.writeHead(503); 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" })); } - 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 org = pdbOrgCountryMap.get(n.org_id) || {}; @@ -5658,75 +5453,7 @@ ${html} }); -// ============================================================ -// 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"); - }); -} +const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes"); // ============================================================ // PeeringDB Org → Country Cache (for Lia's Paradise) diff --git a/server/atlas-probes.js b/server/atlas-probes.js new file mode 100644 index 0000000..1576906 --- /dev/null +++ b/server/atlas-probes.js @@ -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 }; diff --git a/server/hijack-monitoring/monitor.js b/server/hijack-monitoring/monitor.js new file mode 100644 index 0000000..0b60456 --- /dev/null +++ b/server/hijack-monitoring/monitor.js @@ -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 }; diff --git a/server/hijack-monitoring/store.js b/server/hijack-monitoring/store.js new file mode 100644 index 0000000..cc84257 --- /dev/null +++ b/server/hijack-monitoring/store.js @@ -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, +}; diff --git a/server/hijack-monitoring/webhooks.js b/server/hijack-monitoring/webhooks.js new file mode 100644 index 0000000..938a4b9 --- /dev/null +++ b/server/hijack-monitoring/webhooks.js @@ -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 }; diff --git a/server/services/manrs.js b/server/services/manrs.js new file mode 100644 index 0000000..6276dd1 --- /dev/null +++ b/server/services/manrs.js @@ -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 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 267490 — may contain multiple space-separated ASNs + const set = new Set(); + const re = /]*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 };