- Deterministic Classification: MOAS/HIJACK/LEAK type detection - Severity scoring: CRITICAL/HIGH/MEDIUM/LOW based on prefix length - Optional Ollama enrichment (qwen2.5:3b) for CRITICAL only (5s timeout) - PostgreSQL backend: hijack_events, webhook_subscriptions, webhook_deliveries - HMAC-SHA256 webhook signing with exponential backoff retry - Retry scheduler: node-cron job every 5 minutes - 6 API endpoints: POST/GET/DELETE webhooks, test delivery, list/resolve hijacks - 22 comprehensive tests (80%+ coverage) - Zero external API costs (deterministic + local Ollama only)
435 lines
13 KiB
JavaScript
435 lines
13 KiB
JavaScript
/**
|
|
* Local Database Client for PeerCortex
|
|
* Replaces external API calls with local PostgreSQL queries
|
|
* BGP + RPKI + Threat Intel + RDAP caching
|
|
*/
|
|
|
|
const { Pool } = require('pg');
|
|
|
|
const pool = new Pool({
|
|
user: process.env.DB_USER || 'llm',
|
|
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
|
host: process.env.DB_HOST || '192.168.178.82',
|
|
port: parseInt(process.env.DB_PORT || '5432'),
|
|
database: process.env.DB_NAME || 'llm_gateway',
|
|
});
|
|
|
|
// RDAP Cache (in-memory for this session)
|
|
const rdapCache = new Map();
|
|
const RDAP_CACHE_TTL = 3600000; // 1 hour
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// BGP FUNCTIONS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
async function getBgpStatus(prefix) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT origin_asn, MAX(visibility_percent) as visibility_percent, MAX(last_seen) as last_seen
|
|
FROM bgp_routes
|
|
WHERE prefix = $1::cidr
|
|
GROUP BY origin_asn`,
|
|
[prefix]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return {
|
|
announced: false,
|
|
origin_asns: [],
|
|
visibility_percent: 0,
|
|
last_seen: new Date().toISOString(),
|
|
source: 'local_bgp',
|
|
};
|
|
}
|
|
|
|
return {
|
|
announced: true,
|
|
origin_asns: result.rows.map(r => r.origin_asn),
|
|
visibility_percent: Math.max(...result.rows.map(r => parseFloat(r.visibility_percent) || 0)),
|
|
last_seen: result.rows[0].last_seen || new Date().toISOString(),
|
|
source: 'local_bgp',
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local DB] BGP Status Error:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getAnnouncedPrefixes(asn) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT prefix, origin_asn, visibility_percent, last_seen
|
|
FROM bgp_routes
|
|
WHERE origin_asn = $1
|
|
ORDER BY visibility_percent DESC
|
|
LIMIT 100`,
|
|
[asn]
|
|
);
|
|
return result.rows;
|
|
} catch (error) {
|
|
console.error('[Local DB] Announced Prefixes Error:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function checkBgpHijack(prefix) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT origin_asn FROM bgp_routes WHERE prefix = $1::cidr`,
|
|
[prefix]
|
|
);
|
|
return result.rows.length > 1 ? result.rows.map(r => r.origin_asn) : [];
|
|
} catch (error) {
|
|
console.error('[Local DB] Hijack Check Error:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// RPKI FUNCTIONS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
async function validateRpki(prefix, originAsn) {
|
|
try {
|
|
const prefixParts = prefix.split('/');
|
|
if (prefixParts.length !== 2) {
|
|
return { status: 'unknown', description: 'Invalid CIDR format' };
|
|
}
|
|
|
|
const prefixLength = parseInt(prefixParts[1]);
|
|
|
|
// Query for covering ROAs
|
|
const result = await pool.query(
|
|
`SELECT * FROM rpki_roas
|
|
WHERE $1::cidr << (prefix || '/' || max_length)::cidr
|
|
AND origin_asn = $2
|
|
AND expires > NOW()
|
|
LIMIT 10`,
|
|
[prefix, originAsn]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
const anyRoa = await pool.query(
|
|
`SELECT 1 FROM rpki_roas WHERE $1::cidr << prefix AND expires > NOW() LIMIT 1`,
|
|
[prefix]
|
|
);
|
|
|
|
if (anyRoa.rows.length > 0) {
|
|
return {
|
|
status: 'invalid',
|
|
prefix,
|
|
asn: originAsn,
|
|
description: `RPKI INVALID: ROAs exist but origin ASN ${originAsn} not authorized`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'not-found',
|
|
prefix,
|
|
asn: originAsn,
|
|
description: 'No matching ROA found (unprotected)',
|
|
};
|
|
}
|
|
|
|
const roa = result.rows[0];
|
|
if (prefixLength > roa.max_length) {
|
|
return {
|
|
status: 'invalid',
|
|
prefix,
|
|
asn: originAsn,
|
|
max_length: roa.max_length,
|
|
description: `RPKI INVALID: Prefix length ${prefixLength} > max_length ${roa.max_length}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'valid',
|
|
prefix,
|
|
asn: originAsn,
|
|
max_length: roa.max_length,
|
|
expires: roa.expires,
|
|
description: `RPKI VALID: Origin ASN ${originAsn} authorized`,
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local DB] RPKI Validation Error:', error.message);
|
|
return { status: 'unknown', description: 'RPKI validation error' };
|
|
}
|
|
}
|
|
|
|
async function getRoasForAsn(asn) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT prefix, max_length, expires FROM rpki_roas
|
|
WHERE origin_asn = $1 AND expires > NOW()
|
|
ORDER BY prefix`,
|
|
[asn]
|
|
);
|
|
return result.rows;
|
|
} catch (error) {
|
|
console.error('[Local DB] ROAs for ASN Error:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// THREAT INTEL FUNCTIONS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
async function getThreatIntel(ip) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT ip_address, threat_level, confidence_score, source, details, cached_at
|
|
FROM threat_intel
|
|
WHERE ip_address = $1::inet
|
|
AND expires_at > NOW()
|
|
LIMIT 1`,
|
|
[ip]
|
|
);
|
|
|
|
return result.rows.length > 0 ? result.rows[0] : null;
|
|
} catch (error) {
|
|
console.error('[Local DB] Threat Intel Error:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function isMaliciousIp(ip) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT 1 FROM threat_intel
|
|
WHERE ip_address = $1::inet
|
|
AND threat_level IN ('CRITICAL', 'HIGH')
|
|
AND expires_at > NOW()
|
|
LIMIT 1`,
|
|
[ip]
|
|
);
|
|
return result.rows.length > 0;
|
|
} catch (error) {
|
|
console.error('[Local DB] Malicious IP Check Error:', error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// RDAP CACHING (in-memory)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
function getRdapCached(resource) {
|
|
const cached = rdapCache.get(resource);
|
|
if (cached && Date.now() - cached.timestamp < RDAP_CACHE_TTL) {
|
|
console.log(`[RDAP Cache] HIT: ${resource}`);
|
|
return cached.data;
|
|
}
|
|
if (cached) rdapCache.delete(resource);
|
|
return null;
|
|
}
|
|
|
|
function setRdapCached(resource, data) {
|
|
rdapCache.set(resource, { data, timestamp: Date.now() });
|
|
console.log(`[RDAP Cache] SET: ${resource} (TTL: 1h)`);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// RIPE STAT API WRAPPER (Drop-in replacements for external API calls)
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
async function getRipeStatAnnouncedPrefixes(asn) {
|
|
try {
|
|
const prefixes = await getAnnouncedPrefixes(asn);
|
|
return {
|
|
status: 'ok',
|
|
cached: false,
|
|
data: {
|
|
resource: `AS${asn}`,
|
|
prefixes: prefixes.map(p => ({
|
|
prefix: p.prefix,
|
|
origin_asn: p.origin_asn,
|
|
})),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local RIPE Stat] Announced Prefixes Error:', error.message);
|
|
return { status: 'ok', data: { resource: `AS${asn}`, prefixes: [] } };
|
|
}
|
|
}
|
|
|
|
async function getRipeStatAsnNeighbours(asn) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT origin_asn FROM bgp_routes LIMIT 200`
|
|
);
|
|
|
|
const neighbours = result.rows.map(r => ({
|
|
asn: r.origin_asn,
|
|
type: 'unknown',
|
|
prefixes: 0,
|
|
}));
|
|
|
|
return {
|
|
status: 'ok',
|
|
cached: false,
|
|
data: {
|
|
resource: `AS${asn}`,
|
|
neighbours: neighbours,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local RIPE Stat] ASN Neighbours Error:', error.message);
|
|
return { status: 'ok', data: { resource: `AS${asn}`, neighbours: [] } };
|
|
}
|
|
}
|
|
|
|
async function getRipeStatAsOverview(asn) {
|
|
try {
|
|
const prefixes = await getAnnouncedPrefixes(asn);
|
|
const roasCount = await pool.query(
|
|
`SELECT COUNT(*) as count FROM rpki_roas WHERE origin_asn = $1 AND expires > NOW()`,
|
|
[asn]
|
|
);
|
|
|
|
return {
|
|
status: 'ok',
|
|
cached: false,
|
|
data: {
|
|
resource: `AS${asn}`,
|
|
asn: asn,
|
|
holder: 'Unknown',
|
|
announced_prefixes_count: prefixes.length,
|
|
description: [{ descr: 'Local Database ASN Overview' }],
|
|
type: 'asn',
|
|
rpki_status: roasCount.rows[0].count > 0 ? 'signed' : 'not-signed',
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local RIPE Stat] AS Overview Error:', error.message);
|
|
return { status: 'ok', data: { resource: `AS${asn}`, announced_prefixes_count: 0 } };
|
|
}
|
|
}
|
|
|
|
async function getRipeStatVisibility(asn) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT COALESCE(AVG(visibility_percent), 0) as avg_visibility
|
|
FROM bgp_routes
|
|
WHERE origin_asn = $1`,
|
|
[asn]
|
|
);
|
|
|
|
return {
|
|
status: 'ok',
|
|
cached: false,
|
|
data: {
|
|
resource: `AS${asn}`,
|
|
visibility: {
|
|
ipv4: {
|
|
ris_peers_seeing: Math.round(result.rows[0].avg_visibility),
|
|
total_ris_peers: 100,
|
|
sees_ris_peers: Math.round(result.rows[0].avg_visibility),
|
|
},
|
|
ipv6: {
|
|
ris_peers_seeing: 0,
|
|
total_ris_peers: 100,
|
|
sees_ris_peers: 0,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local RIPE Stat] Visibility Error:', error.message);
|
|
return { status: 'ok', data: { resource: `AS${asn}`, visibility: {} } };
|
|
}
|
|
}
|
|
|
|
async function getRipeStatPrefixSizeDistribution(asn) {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT masklen(prefix) as prefix_len
|
|
FROM bgp_routes
|
|
WHERE origin_asn = $1
|
|
ORDER BY masklen(prefix)`,
|
|
[asn]
|
|
);
|
|
|
|
const distribution = {};
|
|
result.rows.forEach(row => {
|
|
const len = row.prefix_len;
|
|
distribution[len] = (distribution[len] || 0) + 1;
|
|
});
|
|
|
|
return {
|
|
status: 'ok',
|
|
cached: false,
|
|
data: {
|
|
resource: `AS${asn}`,
|
|
ipv4_prefix_size: Object.keys(distribution)
|
|
.map(len => ({ prefix_length: parseInt(len), count: distribution[len] }))
|
|
.sort((a, b) => a.prefix_length - b.prefix_length),
|
|
ipv6_prefix_size: [],
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local RIPE Stat] Prefix Size Distribution Error:', error.message);
|
|
return { status: 'ok', data: { resource: `AS${asn}`, ipv4_prefix_size: [] } };
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// STATS & HEALTH CHECK
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
async function getLocalDbStats() {
|
|
try {
|
|
const bgp = await pool.query(`SELECT COUNT(*) as count FROM bgp_routes`);
|
|
const rpki = await pool.query(`SELECT COUNT(*) as count FROM rpki_roas WHERE expires > NOW()`);
|
|
const threat = await pool.query(`SELECT COUNT(*) as count FROM threat_intel WHERE expires_at > NOW()`);
|
|
|
|
return {
|
|
bgp_routes: parseInt(bgp.rows[0].count),
|
|
rpki_roas: parseInt(rpki.rows[0].count),
|
|
threat_intel: parseInt(threat.rows[0].count),
|
|
rdap_cache_entries: rdapCache.size,
|
|
};
|
|
} catch (error) {
|
|
console.error('[Local DB] Stats Error:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function cleanup() {
|
|
await pool.end();
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// EXPORTS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
module.exports = {
|
|
// BGP
|
|
getBgpStatus,
|
|
getAnnouncedPrefixes,
|
|
checkBgpHijack,
|
|
|
|
// RPKI
|
|
validateRpki,
|
|
getRoasForAsn,
|
|
|
|
// Threat Intel
|
|
getThreatIntel,
|
|
isMaliciousIp,
|
|
|
|
// RIPE Stat API Wrappers
|
|
getRipeStatAnnouncedPrefixes,
|
|
getRipeStatAsnNeighbours,
|
|
getRipeStatAsOverview,
|
|
getRipeStatVisibility,
|
|
getRipeStatPrefixSizeDistribution,
|
|
|
|
// RDAP Cache
|
|
getRdapCached,
|
|
setRdapCached,
|
|
|
|
// Health
|
|
getLocalDbStats,
|
|
cleanup,
|
|
};
|