2026-07-16 deep-dive audit (server.js, local-db-client.js, src/aspa/validator.ts) found the flagship ASPA path verification producing wrong results, and a recurring pattern where an upstream API or the local Postgres DB failing would present as "checked, clean" instead of surfacing failure. All fixes in this commit are empirically or textually verified against primary sources (the IETF draft's actual algorithm text, RFC 5735/6890 bogon ranges, and hand-built controlled test cases with known-correct answers) -- not just read-and-guessed. CRITICAL: ASPA hop-check direction was reversed -------------------------------------------------- verifyUpstream/verifyDownstream/detectValleys called hopCheck(collapsed[i-1], collapsed[i]) -- checking the PROVIDER-side AS's ASPA object for the CUSTOMER, the opposite of draft-ietf-sidrops-aspa-verification section 5.3's authorized(A(I), A(I+1)) (A(I) = customer, always checked first). Proven empirically: a hand-built, fully-attested, zero-leak 3-AS path returned "Invalid" before this fix. verifyDownstream needed a full rewrite, not just an argument swap -- a downstream path can legitimately contain both an up-ramp AND a down-ramp (e.g. origin -> providers -> peering point -> validator's providers -> validator), and the old K/L/uMin/vMax computation didn't correctly implement the draft's max_up_ramp/min_up_ramp/max_down_ramp/min_down_ramp four-way computation. Rewrote it from the draft's exact halt conditions, verified against a worked example fetched from the draft itself (an N=4 path with a single unattested "kink" correctly resolves Valid) plus 3 hand-built scenarios (pure up-ramp, mixed up+down clean, and a definite leak with failures on both ends -- Invalid). Same bug existed independently in src/aspa/validator.ts (the MCP-server- side validator, different codebase, same conceptual error) -- validateUpstream was already correct there, but validateDownstream naively reversed the path and reapplied upstream logic, which cannot represent a legitimate down-ramp. Rewrote it with the same four-ramp algorithm, cross-validated against the same test scenarios through the compiled output. Also in the same /api/aspa/verify handler: aspaStore.set(providerAsn, new Set()) for detected-but-unattested providers made hopCheck report "NotProviderPlus" (implying a real ASPA object that denies the hop) instead of "NoAttestation" (we don't know) -- removed; hopCheck's own `!providers` branch already handles the unset case correctly. Also removed a fallback that fabricated an ASPA declaration from heuristically-detected BGP neighbours when aspa_object_exists is false, which contradicted that exact field in the same response. ROOT CAUSE of ~9 broken checks: duplicate function declaration -------------------------------------------------------------- Two `async function fetchRipeStatCached` declarations existed ~150 lines apart (the original real RIPE-Stat-fetch+cache+throttle implementation, and a newer local-DB-routing wrapper added during the Postgres refactor). In JS, the second declaration silently wins for every call site in the file. The wrapper only recognized 5 URL patterns (announced-prefixes, asn-neighbours, as-overview, visibility, prefix-size-distribution) and returned null unconditionally for everything else -- silently breaking blocklist (Spamhaus), abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois, reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups. Renamed the original to fetchRipeStatCachedFromApi and made the wrapper fall through to it instead of returning null. Verified via an isolated routing test (blocklist/abuse-contact-finder URLs now reach the real implementation; the 5 known patterns still route to local DB). Silent-failure / fake-success patterns fixed --------------------------------------------- - local-db-client.js: 5 getRipeStat* functions returned status:'ok' with empty data on Postgres error (fabricated success) -- now status:'error'. server.js's /api/lookup now tracks which sources degraded, surfaces meta.degraded_sources, and caches a degraded result for 90s instead of the full 5min so a DB hiccup doesn't get repeated to every visitor. - validateRPKIWithCache: DB errors and genuine "no covering ROA" both produced status:"not_found" -- split into "not_found" (real) vs "unavailable" (DB error). Updated all 5 call sites that compute RPKI coverage percentages to exclude "unavailable" from the denominator (a DB hiccup must not lower a network's displayed RPKI/ASPA score), and fixed a pre-existing separate typo in the PDF report generator that checked for 'not-found' (hyphen) when the actual value is 'not_found' (underscore). - crossCheckRpki: an empty sample or a failed RIPE-validator lookup both counted as a fabricated 100%/"agreement" instead of being excluded -- now returns agreement_pct:null when nothing was actually compared, and the caller no longer averages null into overall_confidence. Also documented (not silently left) that bgp.he.net fetching is hardcoded off, so the "2-source" prefix/neighbour cross-checks never actually run -- dataQuality.cross_checks now reports sources:1 honestly instead of claiming a cross-check that never happened. - lookupAspaFromRpki: added feedLoaded so a Cloudflare-RPKI-feed fetch failure (rpkiAspaLastFetch never set) is distinguishable from "this ASN genuinely has no ASPA object" -- surfaced as aspa_feed_healthy on both ASPA endpoints. - Bogon/IRR/Spamhaus-blocklist/reverse-DNS/BGP-visibility checks: fetch failure -> empty result -> "pass" (or, for abuse-contact/rdns, a misleading "fail") -- now report "info" (excluded from the weighted health score, matching the existing checkManrsMembership pattern) when the underlying data source didn't actually respond. - Hijack monitoring: checkHijacksForAsn returned [] on DB error, indistinguishable from a genuine zero-prefix result. On a subscription's first monitoring cycle this permanently persisted an empty baseline, silently disabling leak detection forever while the UI kept showing it as active. Now returns null on failure; runHijackCheck skips the cycle entirely (retries next time) instead of locking in a bad baseline. Fixed 2 other call sites that would otherwise crash or persist null onto disk. - WHOIS cache: a transient failure across all 5 RIR sources (RIPE + 4 RDAP endpoints) was cached as "not found in any RIR database" for 24h, identical to a genuinely non-existent ASN. Added a 10min TTL for that specific case instead. - /api/relationships: a neighbourData fetch failure produced "0 upstreams/downstreams/peers", cached unconditionally for 10min. Applied the same 90s-on-suspicious-zero guard the codebase already uses for /api/validate (commit 4cf1673) but never applied here. Verification ------------ node --check on both .js files, tsc --noEmit clean on every touched TS module (remaining errors are 100% pre-existing, in the unrelated mcp-server subsystem), npm run build succeeds, isolated empirical tests for both the ASPA algorithm (3 hand-built scenarios with known-correct answers, cross-checked against draft text fetched live) and the fetchRipeStatCached routing fix, and a full local boot test reaching the exact same pre-existing sandbox-only crash point (missing PEERINGDB_API_KEY, unrelated to this commit) as a baseline boot before any of today's changes. Not deployed to Erik as part of this commit -- server.js and public/index.html there are untouched.
438 lines
14 KiB
JavaScript
438 lines
14 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 [];
|
|
}
|
|
}
|
|
|
|
// Returns null on DB error (check could not run) vs [] for a genuine single/no-origin
|
|
// result -- a caller treating both as "no hijack" would silently disable hijack
|
|
// detection on every transient DB hiccup while still showing the check as clean.
|
|
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 null;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// 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: 'error', error: error.message, 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: 'error', error: error.message, 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: 'error', error: error.message, 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: 'error', error: error.message, 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: 'error', error: error.message, 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,
|
|
};
|