diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index 7fa7817..6b011f6 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -24,5 +24,10 @@
{"d":"2026-04-09","t":"FIX","m":"aspath/rpki-history/looking-glass/communities: fetchJSONWithRetry with 15-20s timeouts replaced by fetchJSON 5-6s — was causing 40-72s hangs"}
{"d":"2026-04-09","t":"FIX","m":"loadCommunities/loadIrrAudit/loadRpkiHistory/loadAspath/loadHijackMonitor: add AbortController 8-10s — cards no longer spin forever"}
{"d":"2026-04-09","t":"FIX","m":"renderResilienceScore + renderRouteLeak: functions were called but never defined — caused JS crash 'is not defined' breaking entire doLookup render"}
-{"d":"2026-04-09","t":"INFRA","m":"Production git synced to GitHub main (11 commits ahead fixed via git pull); deploy.sh script added for future deployments"}
-{"d":"2026-04-09","t":"INFRA","m":"PeeringDB SQLite daily cron: peeringdb sync at 03:00 UTC, refresh-peeringdb.sh installed on Erik; DB refreshed 34302→34387 networks"}
+{"d":"2026-04-09","t":"FIX","m":"renderResilienceScore + renderRouteLeak: functions called but never defined — JS exception in doLookup aborted all card loads (WHOIS, Health, ASPA, BGPRoutes never rendered)"}
+{"d":"2026-04-09","t":"UI","m":"Score breakdown card: fix dark-theme color bleed onto light design — transparent background, correct border color, sharp corners, ink-blue header"}
+{"d":"2026-04-09","t":"UI","m":"Nav cleanup: removed GitHub + Changelog from nav bar; added to masthead with Blog link and BMAC support badge"}
+{"d":"2026-04-09","t":"INFRA","m":"Server migration completed: PeerCortex moved to new dedicated server with 128GB RAM; production codebase synced, all environment variables verified, deploy.sh script added"}
+{"d":"2026-04-09","t":"INFRA","m":"PeeringDB SQLite daily refresh cron at 03:00 UTC — database updated from 34302 to 34387 networks"}
+{"d":"2026-04-09","t":"FIX","m":"Cloudflare tunnel returning 502: old server still running cloudflared after migration, competing for traffic — stopped on old server, auto-cleanup cron added as safeguard"}
+{"d":"2026-04-09","t":"INFRA","m":"Production server boot persistence: PM2 process list saved, cloudflared auto-restart on crash enabled, all Docker Compose stacks configured for automatic restart on reboot"}
diff --git a/deploy/public/index.html b/deploy/public/index.html
index 88ba74e..4ca6f53 100644
--- a/deploy/public/index.html
+++ b/deploy/public/index.html
@@ -4,6 +4,7 @@
PeerCortex - Network Intelligence Dashboard
+
diff --git a/lama2/01_search_asn.l2 b/lama2/01_search_asn.l2
new file mode 100644
index 0000000..b001630
--- /dev/null
+++ b/lama2/01_search_asn.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/search?q=AS13335
diff --git a/lama2/02_search_prefix.l2 b/lama2/02_search_prefix.l2
new file mode 100644
index 0000000..94cd667
--- /dev/null
+++ b/lama2/02_search_prefix.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/search?q=1.1.1.0%2F24
diff --git a/lama2/03_irr_audit.l2 b/lama2/03_irr_audit.l2
new file mode 100644
index 0000000..31adccd
--- /dev/null
+++ b/lama2/03_irr_audit.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/irr-audit?asn=13335
diff --git a/lama2/04_rpki_history.l2 b/lama2/04_rpki_history.l2
new file mode 100644
index 0000000..1bfbf2e
--- /dev/null
+++ b/lama2/04_rpki_history.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/rpki-history?prefix=1.1.1.0%2F24
diff --git a/lama2/05_aspath.l2 b/lama2/05_aspath.l2
new file mode 100644
index 0000000..e8e4ff1
--- /dev/null
+++ b/lama2/05_aspath.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/aspath?asn=13335
diff --git a/lama2/06_looking_glass.l2 b/lama2/06_looking_glass.l2
new file mode 100644
index 0000000..c781f11
--- /dev/null
+++ b/lama2/06_looking_glass.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/looking-glass?asn=13335
diff --git a/lama2/07_ix_matrix.l2 b/lama2/07_ix_matrix.l2
new file mode 100644
index 0000000..67677ca
--- /dev/null
+++ b/lama2/07_ix_matrix.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/ix-matrix?asn=13335
diff --git a/lama2/08_hijack_alerts.l2 b/lama2/08_hijack_alerts.l2
new file mode 100644
index 0000000..602f13a
--- /dev/null
+++ b/lama2/08_hijack_alerts.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/hijack-alerts?asn=13335
diff --git a/lama2/09_rib_routers.l2 b/lama2/09_rib_routers.l2
new file mode 100644
index 0000000..69c5b74
--- /dev/null
+++ b/lama2/09_rib_routers.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/rib/routers
diff --git a/lama2/10_prefix_changes.l2 b/lama2/10_prefix_changes.l2
new file mode 100644
index 0000000..8a4dd77
--- /dev/null
+++ b/lama2/10_prefix_changes.l2
@@ -0,0 +1 @@
+GET https://peercortex.org/api/prefix-changes?prefix=1.1.1.0%2F24
diff --git a/local-db-client.js b/local-db-client.js
new file mode 100644
index 0000000..9a1a041
--- /dev/null
+++ b/local-db-client.js
@@ -0,0 +1,283 @@
+/**
+ * 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)`);
+}
+
+// ══════════════════════════════════════════════════════════════
+// 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,
+
+ // RDAP Cache
+ getRdapCached,
+ setRdapCached,
+
+ // Health
+ getLocalDbStats,
+ cleanup,
+};
diff --git a/public/index.html b/public/index.html
index 243275e..c46437f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,8 @@
PeerCortex — The ASN News
+
+
diff --git a/server.js b/server.js
index 70aba62..ffda5bf 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,10 @@ const http = require("http");
const https = require("https");
const crypto = require("crypto");
+// ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ──────────────
+const localDb = require('./local-db-client');
+console.log('[PeerCortex] Local DB client initialized');
+
// Load .env file
const envPath = "/opt/peercortex-app/.env";
try {
@@ -1347,27 +1351,24 @@ async function resolveASNames(providers) {
// RPKI per-prefix validation — uses local ROA store (instant, no API calls)
// Falls back to RIPE Stat only if ROA store is not yet loaded (cold start)
-function fetchRPKIPerPrefix(asn, prefix) {
- // Try local ROA store first (sub-millisecond)
- const local = roaStore.validate(asn, prefix);
- if (local !== null) return Promise.resolve(local);
-
- // Fallback: RIPE Stat API (only during cold start before first feed load)
- return fetchRipeStatCached(
- "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS" +
- asn + "&prefix=" + encodeURIComponent(prefix)
- ).then((r) => {
- const status = r?.data?.status || "not_found";
- const validating = r?.data?.validating_roas || [];
- return { prefix, status, validating_roas: validating.length };
- });
-}
-
-// Validate RPKI for a prefix — local ROA store (instant) or RIPE Stat fallback
+// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls)
+// Returns: { prefix, status: "valid"|"invalid"|"not_found", validating_roas: N }
async function validateRPKIWithCache(asn, prefix) {
try {
- return await fetchRPKIPerPrefix(asn, prefix);
+ // Query local database (sub-10ms, no external API calls)
+ const result = await localDb.validateRpki(prefix, asn);
+
+ // Adapt response to match expected format
+ if (result.status === 'valid') {
+ return { prefix, status: "valid", validating_roas: 1 };
+ } else if (result.status === 'invalid') {
+ return { prefix, status: "invalid", validating_roas: 1 };
+ } else {
+ // 'not-found' or 'unknown'
+ return { prefix, status: "not_found", validating_roas: 0 };
+ }
} catch (_e) {
+ console.error("[RPKI] Error validating " + prefix + ":", _e.message);
return { prefix, status: "not_found", validating_roas: 0 };
}
}
@@ -2344,16 +2345,20 @@ const server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
- // Health endpoint — extended with cache status and ASPA metrics
+ // Health endpoint — extended with cache status, ASPA metrics, and local DB stats
if (reqPath === "/api/health") {
const mem = process.memoryUsage();
- const roaAge = roaStore.lastBuild ? Math.floor((Date.now() - roaStore.lastBuild) / 60000) : -1;
const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
- const status = roaStore.ready && aspaAge < 300 ? "ok" : "degraded";
- return res.end(
- JSON.stringify({
+ // Query local DB stats (async, but return partial if needed)
+ localDb.getLocalDbStats().then(function(dbStats) {
+ // Determine health status based on local DB data availability
+ const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
+ const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
+ const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";
+
+ const healthResponse = {
status,
service: "PeerCortex",
version: "0.6.9",
@@ -2362,7 +2367,6 @@ const server = http.createServer(async (req, res) => {
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
bgproutes_configured: !!BGPROUTES_API_KEY,
caches: {
- roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
@@ -2370,16 +2374,58 @@ const server = http.createServer(async (req, res) => {
ripe_stat: { entries: ripeStatCache.size },
response_cache: { entries: responseCache.size },
},
+ local_db: dbStats ? {
+ bgp_routes: dbStats.bgp_routes,
+ rpki_roas: dbStats.rpki_roas,
+ threat_intel: dbStats.threat_intel,
+ rdap_cache_entries: dbStats.rdap_cache_entries,
+ source: "PostgreSQL (local)",
+ healthy: hasLocalBgp && hasLocalRpki,
+ } : null,
aspa_adoption: {
total_objects: rpkiAspaMap.size,
- roa_count: roaStore.count,
+ roa_count: dbStats ? dbStats.rpki_roas : 0,
history_samples: aspaAdoptionHistory.length,
delta_last: aspaAdoptionHistory.length >= 2
? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
: 0,
},
- })
- );
+ };
+ return res.end(JSON.stringify(healthResponse, null, 2));
+ }).catch(function(e) {
+ console.error('[/api/health] Local DB stats error:', e.message);
+ // Return health without local DB stats on error
+ return res.end(
+ JSON.stringify({
+ status,
+ service: "PeerCortex",
+ version: "0.6.9",
+ timestamp: new Date().toISOString(),
+ uptime_seconds: Math.floor(process.uptime()),
+ memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
+ bgproutes_configured: !!BGPROUTES_API_KEY,
+ caches: {
+ roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
+ aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
+ pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
+ pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
+ pdb_netfac: { entries: pdbSourceCache.netfac.size },
+ ripe_stat: { entries: ripeStatCache.size },
+ response_cache: { entries: responseCache.size },
+ },
+ local_db: { error: "Could not fetch local DB stats", message: e.message },
+ aspa_adoption: {
+ total_objects: rpkiAspaMap.size,
+ roa_count: roaStore.count,
+ history_samples: aspaAdoptionHistory.length,
+ delta_last: aspaAdoptionHistory.length >= 2
+ ? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
+ : 0,
+ },
+ }, null, 2)
+ );
+ });
+ return;
}
// ============================================================
@@ -2770,140 +2816,104 @@ const server = http.createServer(async (req, res) => {
}
// ============================================================
- // bgproutes.io endpoint: /api/bgproutes?asn=X (or prefix=X)
+ // BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
+ // Queries local PostgreSQL bgp_routes table — zero external API calls
// ============================================================
- if (reqPath === "/api/bgproutes") {
+ if (reqPath === "/api/bgp") {
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
const prefix = url.searchParams.get("prefix") || "";
if (!rawAsn && !prefix) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
}
- const cacheKeyBgr = rawAsn || prefix;
- const cachedBgr = resultCacheGet(bgproutesResultCache, cacheKeyBgr);
- if (cachedBgr !== undefined) {
- res.writeHead(200, { "Content-Type": "application/json" });
- return res.end(JSON.stringify(cachedBgr));
+ const cacheKey = rawAsn || prefix;
+ const cached = resultCacheGet(bgproutesResultCache, cacheKey);
+ if (cached !== undefined) {
+ res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
+ return res.end(JSON.stringify(cached));
}
const start = Date.now();
try {
- const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null };
+ const result = {
+ meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
+ bgp_status: null,
+ threat_intel: null,
+ };
- // Use module-level vantage_points cache (1h TTL) to prevent 429 flooding
- let vpData = null;
- if (bgproutesVpCache && (Date.now() - bgproutesVpCacheTs) < BGPROUTES_VP_TTL) {
- vpData = bgproutesVpCache;
- } else {
- vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", {
- headers: { "x-api-key": BGPROUTES_API_KEY },
- timeout: 10000,
- });
- if (vpData && !vpData.error) { bgproutesVpCache = vpData; bgproutesVpCacheTs = Date.now(); }
- }
-
- if (vpData && !vpData.error) {
- const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []);
- const readyVPs = Array.isArray(vpList) ? vpList.filter((vp) => !vp.status || (Array.isArray(vp.status) && vp.status.includes("ready"))) : [];
- result.vantage_points = {
- count: readyVPs.length,
- total: Array.isArray(vpList) ? vpList.length : 0,
- list: readyVPs.slice(0, 20).map((vp) => ({
- id: vp.id,
- asn: vp.asn,
- ip: vp.ip,
- source: vp.source || "",
- org_name: vp.org_name || "",
- country: vp.org_country || vp.country || "",
- rib_v4: vp.rib_size_v4 || 0,
- rib_v6: vp.rib_size_v6 || 0,
- })),
- };
- } else {
- result.vantage_points = { count: 0, error: "Could not fetch vantage points" };
- }
-
- let ribSuccess = false;
- const readyVPsForRib = result.vantage_points && result.vantage_points.list
- ? result.vantage_points.list.filter((vp) => vp.rib_v4 > 500000).slice(0, 1)
- : [];
-
- if (readyVPsForRib.length > 0) {
- const vpId = readyVPsForRib[0].id;
- const now = new Date().toISOString().replace(/\.\d+Z$/, "");
- const ribBody = {
- vp_bgp_ids: String(vpId),
- date: now,
- return_aspath: true,
- return_rov_status: true,
- return_aspa_status: true,
- };
-
- if (prefix) {
- ribBody.prefix_exact_match = prefix;
- } else if (rawAsn) {
- ribBody.aspath_regexp = rawAsn + "$";
- }
-
- try {
- const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, {
- headers: { "x-api-key": BGPROUTES_API_KEY },
- timeout: 6000,
- });
-
- if (ribData && ribData.data) {
- const bgpData = ribData.data.bgp || {};
- const vpRoutes = bgpData[String(vpId)] || {};
- const routeEntries = Object.entries(vpRoutes).map(([pfx, arr]) => {
- const asPath = Array.isArray(arr) ? arr[0] || "" : "";
- const rovStatus = Array.isArray(arr) ? arr[2] || "" : "";
- const aspaStatus = Array.isArray(arr) ? arr[3] || "" : "";
- return {
- prefix: pfx,
- as_path: asPath,
- rov_status: (function(rs) {
- var parts = rs.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; });
- if (parts.indexOf("invalid") >= 0) return "invalid";
- if (parts.indexOf("unknown") >= 0) return "unknown";
- if (parts.indexOf("valid") >= 0) return "valid";
- return parts[0] || "unknown";
- })(rovStatus),
- aspa_status: (function(as) {
- var parts = as.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; });
- if (parts.indexOf("invalid") >= 0) return "invalid";
- if (parts.indexOf("unknown") >= 0) return "unknown";
- if (parts.indexOf("valid") >= 0) return "valid";
- return parts[0] || "unknown";
- })(aspaStatus),
- };
- });
-
- if (routeEntries.length > 0) {
- result.routes = {
- count: routeEntries.length,
- vp_used: { id: vpId, org: readyVPsForRib[0].org_name, country: readyVPsForRib[0].country },
- sample: routeEntries.slice(0, 20),
- };
- ribSuccess = true;
- }
+ // ---- BGP Status (local DB lookup) ----
+ if (prefix) {
+ // Prefix lookup: Get BGP status for this prefix
+ const bgpStatus = await localDb.getBgpStatus(prefix);
+ if (bgpStatus) {
+ result.bgp_status = {
+ prefix,
+ announced: bgpStatus.announced,
+ origin_asns: bgpStatus.origin_asns,
+ visibility_percent: bgpStatus.visibility_percent,
+ last_seen: bgpStatus.last_seen,
+ source: "local_bgp",
+ };
+ // Check for hijack (multiple origin ASNs)
+ const hijackAsns = await localDb.checkBgpHijack(prefix);
+ if (hijackAsns.length > 1) {
+ result.bgp_status.hijack_warning = {
+ detected: true,
+ origin_asns: hijackAsns,
+ message: `Multiple origin ASNs detected for ${prefix}`,
+ };
}
- } catch (_e) {}
+ }
+ } else if (rawAsn) {
+ // ASN lookup: Get all announced prefixes for this ASN
+ const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
+ if (prefixes && prefixes.length > 0) {
+ result.bgp_status = {
+ asn: rawAsn,
+ announced_count: prefixes.length,
+ prefixes: prefixes.slice(0, 50).map((p) => ({
+ prefix: p.prefix,
+ origin_asn: p.origin_asn,
+ visibility_percent: p.visibility_percent,
+ last_seen: p.last_seen,
+ })),
+ source: "local_bgp",
+ };
+ } else {
+ result.bgp_status = {
+ asn: rawAsn,
+ announced: false,
+ announced_count: 0,
+ message: "No prefixes found for this ASN in local BGP table",
+ source: "local_bgp",
+ };
+ }
}
- if (!ribSuccess) {
- result.routes = {
- status: "unavailable",
- message: readyVPsForRib.length === 0
- ? "No ready VPs with sufficient RIB size found"
- : "bgproutes.io: VPs available but RIB query returned no data for this ASN",
- };
+ // ---- Threat Intelligence (local cache lookup) ----
+ // If we have an IP context, look up threat intel
+ if (prefix && prefix.includes(".")) {
+ // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
+ const ipAddr = prefix.split("/")[0];
+ const threat = await localDb.getThreatIntel(ipAddr);
+ if (threat) {
+ result.threat_intel = {
+ ip_address: threat.ip_address,
+ threat_level: threat.threat_level,
+ confidence_score: threat.confidence_score,
+ source: threat.source,
+ cached_at: threat.cached_at,
+ };
+ }
}
result.meta.duration_ms = Date.now() - start;
- resultCacheSet(bgproutesResultCache, cacheKeyBgr, result);
+ resultCacheSet(bgproutesResultCache, cacheKey, result);
+ res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(result, null, 2));
} catch (err) {
+ console.error("[/api/bgp] Error:", err.message);
res.writeHead(500);
- return res.end(JSON.stringify({ error: "bgproutes.io query failed", message: err.message }));
+ return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
}
}
@@ -4354,21 +4364,19 @@ const server = http.createServer(async (req, res) => {
const origins = routingStatus?.data?.origins || [];
const firstSeen = routingStatus?.data?.first_seen?.time || null;
- // RPKI validation: use local ROA store (instant) instead of RIPE Stat API call
+ // RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
let rpkiStatus = "unknown";
let rpkiRoas = [];
const originAsn = origins.length > 0 ? origins[0].asn : null;
if (originAsn) {
- await ensureAspaCache();
- const localRpki = roaStore.validate(originAsn, prefix);
- if (localRpki) {
+ try {
+ const localRpki = await validateRPKIWithCache(originAsn, prefix);
rpkiStatus = localRpki.status;
rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
- } else {
- // Fallback to RIPE Stat if ROA store not ready
- const rpkiValid = await fetchRipeStatCached("https://stat.ripe.net/data/rpki-validation/data.json?resource=" + encodeURIComponent(prefix));
- rpkiStatus = rpkiValid?.data?.status || "unknown";
- rpkiRoas = rpkiValid?.data?.validating_roas || [];
+ } catch (e) {
+ console.error("[Prefix Detail] RPKI validation error:", e.message);
+ rpkiStatus = "unknown";
+ rpkiRoas = [];
}
}
var visData = visibility?.data?.visibilities || [];
@@ -5178,8 +5186,16 @@ ${html}
const peer = u.peer || '';
if (u.type === 'A') {
- const rpki = (origin && roaStore.ready) ? roaStore.validate(origin, prefix) : null;
- const rpkiStatus = rpki ? rpki.status : 'unknown';
+ // Query local PostgreSQL for RPKI status (sub-10ms)
+ let rpkiStatus = 'unknown';
+ try {
+ if (origin && prefix) {
+ const rpkiResult = await validateRPKIWithCache(origin, prefix);
+ rpkiStatus = rpkiResult.status;
+ }
+ } catch (e) {
+ console.error("[Prefix Changes] RPKI lookup error:", e.message);
+ }
announcements.push({ prefix, timestamp: ts, peer, origin, rpki_status: rpkiStatus });
if (lastOriginByPrefix[prefix] !== undefined && lastOriginByPrefix[prefix] !== origin) {
diff --git a/src/db/bgp-client.ts b/src/db/bgp-client.ts
new file mode 100644
index 0000000..0199e1b
--- /dev/null
+++ b/src/db/bgp-client.ts
@@ -0,0 +1,119 @@
+import { Pool } from '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', // Erik IPv4
+ port: parseInt(process.env.DB_PORT || '5432'),
+ database: process.env.DB_NAME || 'llm_gateway',
+});
+
+export interface BGPStatus {
+ announced: boolean;
+ origin_asns: number[];
+ visibility_percent: number;
+ last_seen: string;
+}
+
+export interface BGPRoute {
+ prefix: string;
+ origin_asn: number;
+ visibility_percent: number;
+ last_seen: string;
+}
+
+/**
+ * Query local BGP database for prefix status
+ * Returns announced status, origin ASNs, and visibility percentage
+ */
+export async function getBgpStatus(prefix: string): Promise {
+ 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(),
+ };
+ }
+
+ 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(),
+ };
+ } catch (error) {
+ console.error('[BGP Client] Error querying bgp_routes:', error);
+ return null;
+ }
+}
+
+/**
+ * Query local BGP for all prefixes announced by an ASN
+ */
+export async function getAnnouncedPrefixes(asn: number): Promise {
+ 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`,
+ [asn]
+ );
+ return result.rows;
+ } catch (error) {
+ console.error('[BGP Client] Error querying announced prefixes:', error);
+ return [];
+ }
+}
+
+/**
+ * Check for potential BGP hijacks (multiple origin ASNs for same prefix)
+ */
+export async function checkBgpHijack(prefix: string): Promise {
+ 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('[BGP Client] Error checking hijacks:', error);
+ return [];
+ }
+}
+
+/**
+ * Get BGP statistics (total prefixes, ASNs, etc.)
+ */
+export async function getBgpStats() {
+ try {
+ const result = await pool.query(`
+ SELECT
+ COUNT(*) as total_prefixes,
+ COUNT(DISTINCT origin_asn) as total_asns,
+ MAX(last_seen) as last_import,
+ MIN(visibility_percent) as min_visibility,
+ AVG(visibility_percent) as avg_visibility,
+ MAX(visibility_percent) as max_visibility
+ FROM bgp_routes
+ `);
+ return result.rows[0] || null;
+ } catch (error) {
+ console.error('[BGP Client] Error querying stats:', error);
+ return null;
+ }
+}
+
+export async function cleanup() {
+ await pool.end();
+}
diff --git a/src/db/rdap-cache.ts b/src/db/rdap-cache.ts
new file mode 100644
index 0000000..02038a0
--- /dev/null
+++ b/src/db/rdap-cache.ts
@@ -0,0 +1,68 @@
+/**
+ * Redis-based RDAP caching layer
+ * Caches RDAP lookups with 1-hour TTL to reduce external RIR queries
+ * Target: 60% hit rate on repeated lookups within same session
+ */
+
+interface RedisClient {
+ get(key: string): Promise;
+ set(key: string, value: string, ex?: number): Promise;
+ del(key: string): Promise;
+}
+
+let redisClient: RedisClient | null = null;
+
+export function initRedisCache(client: RedisClient): void {
+ redisClient = client;
+ console.log('[RDAP Cache] Redis client initialized');
+}
+
+const RDAP_CACHE_TTL = 3600; // 1 hour
+
+export async function getRdapCached(resource: string): Promise {
+ if (!redisClient) return null;
+
+ const cacheKey = `rdap:${resource}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(`[RDAP Cache] HIT: ${resource}`);
+ return JSON.parse(cached);
+ }
+ } catch (error) {
+ console.error('[RDAP Cache] Error reading cache:', error);
+ }
+
+ return null;
+}
+
+export async function setRdapCached(resource: string, data: any): Promise {
+ if (!redisClient) return;
+
+ const cacheKey = `rdap:${resource}`;
+ try {
+ await redisClient.set(cacheKey, JSON.stringify(data), RDAP_CACHE_TTL);
+ console.log(`[RDAP Cache] SET: ${resource} (TTL: ${RDAP_CACHE_TTL}s)`);
+ } catch (error) {
+ console.error('[RDAP Cache] Error writing cache:', error);
+ }
+}
+
+export async function clearRdapCache(resource: string): Promise {
+ if (!redisClient) return;
+
+ const cacheKey = `rdap:${resource}`;
+ try {
+ await redisClient.del(cacheKey);
+ console.log(`[RDAP Cache] DELETED: ${resource}`);
+ } catch (error) {
+ console.error('[RDAP Cache] Error deleting cache:', error);
+ }
+}
+
+export function getCacheStats() {
+ if (!redisClient) {
+ return { status: 'disabled', message: 'Redis client not initialized' };
+ }
+ return { status: 'enabled', ttl_seconds: RDAP_CACHE_TTL };
+}
diff --git a/src/db/rpki-client.ts b/src/db/rpki-client.ts
new file mode 100644
index 0000000..1a8b613
--- /dev/null
+++ b/src/db/rpki-client.ts
@@ -0,0 +1,134 @@
+import { Pool } from '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',
+});
+
+export interface RpkiValidationResult {
+ status: 'valid' | 'invalid' | 'not-found' | 'unknown';
+ prefix?: string;
+ asn?: number;
+ max_length?: number;
+ expires?: string;
+ description: string;
+}
+
+/**
+ * Validate prefix + origin ASN against local RPKI ROA database
+ * Returns VALID/INVALID/NOT-FOUND/UNKNOWN status
+ */
+export async function validateRpki(prefix: string, originAsn: number): Promise {
+ try {
+ // Parse CIDR prefix to extract base prefix and length
+ 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) {
+ // Check if any ROAs exist for this prefix at all
+ 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 for this prefix but origin ASN ${originAsn} is not authorized`,
+ };
+ }
+
+ return {
+ status: 'not-found',
+ prefix,
+ asn: originAsn,
+ description: 'No matching ROA found (route is unprotected)',
+ };
+ }
+
+ // Validate prefix length against max_length
+ 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} exceeds 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 for ${prefix}`,
+ };
+ } catch (error) {
+ console.error('[RPKI Client] Error validating RPKI:', error);
+ return { status: 'unknown', description: 'RPKI validation error' };
+ }
+}
+
+/**
+ * Get all ROAs for a given ASN
+ */
+export async function getRoasForAsn(asn: number) {
+ 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('[RPKI Client] Error querying ROAs:', error);
+ return [];
+ }
+}
+
+/**
+ * Get RPKI statistics
+ */
+export async function getRpkiStats() {
+ try {
+ const result = await pool.query(`
+ SELECT
+ COUNT(*) as total_roas,
+ COUNT(DISTINCT origin_asn) as covered_asns,
+ MAX(expires) as latest_expiry,
+ COUNT(CASE WHEN expires < NOW() THEN 1 END) as expired_roas
+ FROM rpki_roas
+ `);
+ return result.rows[0] || null;
+ } catch (error) {
+ console.error('[RPKI Client] Error querying stats:', error);
+ return null;
+ }
+}
+
+export async function cleanup() {
+ await pool.end();
+}
diff --git a/src/db/threat-intel-client.ts b/src/db/threat-intel-client.ts
new file mode 100644
index 0000000..41c2d54
--- /dev/null
+++ b/src/db/threat-intel-client.ts
@@ -0,0 +1,105 @@
+import { Pool } from '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',
+});
+
+export interface ThreatIntelligence {
+ ip_address: string;
+ threat_level: string;
+ confidence_score: number;
+ source: string;
+ details?: any;
+ cached_at: string;
+}
+
+/**
+ * Query threat intelligence for an IP address
+ */
+export async function getThreatIntel(ip: string): Promise {
+ 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('[Threat Intel Client] Error querying threat intel:', error);
+ return null;
+ }
+}
+
+/**
+ * Check if an IP is malicious
+ */
+export async function isMaliciousIp(ip: string): Promise {
+ 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('[Threat Intel Client] Error checking malicious IP:', error);
+ return false;
+ }
+}
+
+/**
+ * Get threat statistics
+ */
+export async function getThreatStats() {
+ try {
+ const result = await pool.query(`
+ SELECT
+ COUNT(*) as total_entries,
+ COUNT(CASE WHEN threat_level = 'CRITICAL' THEN 1 END) as critical_count,
+ COUNT(CASE WHEN threat_level = 'HIGH' THEN 1 END) as high_count,
+ COUNT(DISTINCT source) as source_count
+ FROM threat_intel
+ WHERE expires_at > NOW()
+ `);
+ return result.rows[0] || null;
+ } catch (error) {
+ console.error('[Threat Intel Client] Error querying stats:', error);
+ return null;
+ }
+}
+
+/**
+ * Get threats by severity level
+ */
+export async function getThreatsByLevel(level: string) {
+ try {
+ const result = await pool.query(
+ `SELECT ip_address, threat_level, confidence_score, source
+ FROM threat_intel
+ WHERE threat_level = $1
+ AND expires_at > NOW()
+ ORDER BY confidence_score DESC
+ LIMIT 100`,
+ [level]
+ );
+ return result.rows;
+ } catch (error) {
+ console.error('[Threat Intel Client] Error querying by level:', error);
+ return [];
+ }
+}
+
+export async function cleanup() {
+ await pool.end();
+}