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:
Rene Fichtmueller 2026-07-16 23:41:23 +02:00
parent e5c1c2a6ae
commit 63ace5d87a
6 changed files with 347 additions and 294 deletions

315
server.js
View File

@ -254,163 +254,19 @@ 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,
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json'; 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 []; } } const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
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
View 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 };

View 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 };

View 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,
};

View 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
View 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 };