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
|
||||
|
||||
// ── 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 {
|
||||
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");
|
||||
|
||||
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
|
||||
// ============================================================
|
||||
@ -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<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/",
|
||||
};
|
||||
}
|
||||
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)
|
||||
|
||||
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