Compare commits
No commits in common. "f0fe8125e06d3add35dda96d7cc773f6cbd649c3" and "344ee15338c0ec03f0039d37b7722a514b690e55" have entirely different histories.
f0fe8125e0
...
344ee15338
116
CHANGELOG.md
116
CHANGELOG.md
@ -4,72 +4,6 @@ All notable changes to PeerCortex are documented here.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0 — 2026-04-29
|
||||
|
||||
### Added
|
||||
- **BGP Hijack Alerting + Webhooks (Feature 1)** — Real-time detection and alerting for BGP hijacks, MOAS events, and prefix leaks with persistent storage and webhook delivery.
|
||||
- **Deterministic Classification**: Code-based logic classifies hijack type (MOAS/HIJACK/LEAK) and severity (CRITICAL/HIGH/MEDIUM/LOW) based on ASN origin analysis and prefix length.
|
||||
- **Optional Ollama Enrichment**: For CRITICAL severity hijacks, optional local qwen2.5:3b model enriches alert description with impact assessment (5s timeout, graceful fallback).
|
||||
- **PostgreSQL Backend**: Three core tables (hijack_events, webhook_subscriptions, webhook_deliveries) with proper indexing and deduplication (6-hour window per ASN:prefix).
|
||||
- **Webhook Delivery**: HMAC-SHA256 signed POST delivery with exponential backoff retry (1s → 2s → 4s → 8s → 16s), configurable timeout per subscription.
|
||||
- **Retry Scheduler**: node-cron job polls failed deliveries every 5 minutes, auto-retries with configurable max attempts.
|
||||
- **API Endpoints**: POST `/api/webhooks` (register), GET `/api/webhooks` (list), DELETE `/api/webhooks/{id}` (unregister), POST `/api/webhooks/{id}/test` (test delivery), GET `/api/hijacks` (list events), POST `/api/hijacks/{id}/resolve` (mark resolved).
|
||||
|
||||
- **PDF Report Export (Feature 2)** — On-demand, server-side PDF generation for comprehensive network analysis reports with intelligent caching and performance optimization.
|
||||
- **Multiple Formats**: Report (20–25 pages, full analysis), Executive (3–5 pages, C-level summary), Technical (10–15 pages, engineer deep-dive).
|
||||
- **Playwright Integration**: Chrome-based PDF rendering via Playwright with 30s timeout, reusable browser instance, and automatic restart on lifetime exceeded (1h max).
|
||||
- **Smart Caching**: 5-minute in-memory TTL cache with SHA256 deduplication, database persistence, and automatic cleanup of expired records.
|
||||
- **Template Engine**: Dynamic HTML templates with variable substitution (simple fields, nested objects, arrays with `{{#each}}` loops).
|
||||
- **Memory Management**: Chromium process monitoring, automatic restart if memory exceeds 500MB, per-PDF instance tracking.
|
||||
- **API Endpoints**: GET `/api/export/pdf?asn=13335&format=report|executive|technical` (stream PDF), GET `/api/export/pdf/stats` (cache + DB statistics).
|
||||
|
||||
- **ASPA Adoption Tracker (Feature 3)** — Global adoption trend tracking with daily sampling, regional breakdowns, IXP analysis, and 6-month forecasting.
|
||||
- **Daily Sampler**: Stratified random sampling of 500–1000 ASNs weighted by region and prefix count, deterministic rotation per day.
|
||||
- **ASPA Collector**: Rate-limited parallel fetching (5s timeout per ASN), caches results 24h, tracks provider verification percentage.
|
||||
- **Aggregator**: Groups by region (Africa, APAC, Europe, LatAm, NorthAm, Oceania) and IXP, calculates coverage percentages.
|
||||
- **Forecaster**: Linear regression with 90-day lookback, predicts 6-month adoption, confidence interval (0.0–1.0).
|
||||
- **Scheduled Job**: Runs daily at 2 AM UTC via node-cron, stores results in PostgreSQL with deduplication.
|
||||
- **Public Dashboard**: `/aspa-adoption` page with coverage gauge, 30-day trend line, regional heatmap, top adopters, IXP rankings, forecast box, export buttons (CSV/JSON).
|
||||
- **API Endpoints**: GET `/api/aspa-adoption-stats?period=7d|30d|1y®ion=Global|Europe|NA|APAC` (trend data), GET `/api/aspa-adoption-stats/regional` (by region), GET `/api/aspa-adoption-stats/ixps?top=20` (IXP rankings), GET `/api/aspa-adoption-stats/export?format=csv|json&period=30d` (for blog articles).
|
||||
|
||||
### Fixed
|
||||
- **SQL JOIN column aliasing**: Resolved duplicate column names in multi-table JOINs using explicit prefixes (ws_/he_/wd_) and dynamic key lookup pattern for type-safe mapping.
|
||||
- **Fetch mock AbortSignal support**: Added proper AbortSignal listener support in test mocks to correctly simulate timeout behavior with AbortController.
|
||||
|
||||
### Technical
|
||||
- **Test Coverage**: 215 total tests across all features (22 Hijack + 74 PDF + 67 ASPA + 52 integration tests). Coverage: 80%+ per feature module. All tests passing.
|
||||
- **Zero External API Costs**: Classification and enrichment entirely local — deterministic code + optional Ollama (Feature 1). PDF generation with local Playwright (Feature 2). ASPA data from existing RIPE Stat integration (Feature 3).
|
||||
- **API Server Integration**: All 3 routes registered at `/api/*` with Fastify, health check endpoint (`/health`), root info endpoint.
|
||||
- **PostgreSQL Migrations**: Three migration files (001-hijack-alerts.sql, 002-pdf-reports.sql, 003-aspa-adoption.sql) with proper indexing and constraints.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.9 — 2026-04-05
|
||||
|
||||
### Added
|
||||
- **Resilience Score**: Weighted 4-factor score (1–10) per ASN — Transit Diversity (30%), Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%). Hard cap at 5.0 when only a single transit provider is detected. Shows a large score digit plus four colour-coded progress bars in the UI.
|
||||
- **Route Leak Detection**: Heuristic analysis using RIPE Stat neighbour data. Detects two patterns: *sandwich candidates* (Tier-1 appearing as both upstream and downstream) and *Tier-1 as downstream* (unusual re-origination). Reference set: 21 known Tier-1 ASNs. Confidence: medium — pattern-based, not real-time.
|
||||
- **Data Provenance System**: Every API response field carries `_provenance` metadata — source, validation method (cross-validated / heuristic / computed / single-source), confidence (high / medium / experimental), and an optional note. Shown in the UI as coloured badges next to each card title.
|
||||
- **MCP Server** (`mcp-server.js`): PeerCortex as MCP tools for Claude Desktop and Claude Code — `lookup_asn`, `compare_networks`, `get_health_report`, `search_network`, `get_resilience_score`.
|
||||
- **Rotating Daily Audit**: 100 ASNs tested daily, deterministically rotated via SHA256 date seed. Math checks (prefix sums, RPKI sums, IX dedup) + external cross-validation against RIPE Stat and PeeringDB.
|
||||
- **Daily Audit Email**: HTML report with all tested ASNs, cross-validation columns and critical/warning/ok/skip counts, sent daily at 06:00 UTC.
|
||||
|
||||
### Fixed
|
||||
- **ASN name fallback**: ASNs with no RIPE Stat holder or RDAP data now resolve name and country from `bgp.he.net` page title and country href — eliminates `Unknown` name entries for unassigned blocks and micro-ISPs.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.8 — 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block).
|
||||
- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB).
|
||||
|
||||
### Infrastructure
|
||||
- Daily automated audit introduced: 103 ASNs validated every 24h.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.6 — 2026-04-02
|
||||
|
||||
### Added
|
||||
@ -81,12 +15,6 @@ All notable changes to PeerCortex are documented here.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.5 — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.4 — 2026-04-02
|
||||
|
||||
@ -185,3 +113,47 @@ All notable changes to PeerCortex are documented here.
|
||||
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation |
|
||||
| [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases |
|
||||
| [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion |
|
||||
|
||||
## v0.6.5 — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.
|
||||
|
||||
## [0.6.8] — 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder
|
||||
now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block)
|
||||
- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country
|
||||
now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB)
|
||||
|
||||
### Quality Audit — 2026-04-03 (103 ASNs, dual-run validation)
|
||||
- **0 CRITICAL data errors** across all 103 audited ASNs
|
||||
- **97 PERFECT** — 94% with zero issues
|
||||
- **6 WARNING only** — slow cold-cache on large Tier-1 carriers and minor
|
||||
source disagreement in external registries (not PeerCortex data errors)
|
||||
- All mathematical consistency checks passed 103/103:
|
||||
prefix math · RPKI math · RPKI coverage% · IX dedup · facility counts
|
||||
- Prefix counts cross-validated against RIPE Stat: no deviation >10%
|
||||
- IX connections cross-validated against PeeringDB: no deviation >10%
|
||||
|
||||
### Infrastructure
|
||||
- Daily automated audit introduced: 103 ASNs validated every 24h
|
||||
|
||||
## [0.6.9] — 2026-04-04
|
||||
|
||||
### Added
|
||||
- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%),
|
||||
Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%).
|
||||
Hard cap at 5.0 when single transit provider detected.
|
||||
Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
|
||||
- **Route Leak Detection**: Heuristic pattern detection for suspicious routing
|
||||
relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM —
|
||||
pattern-based, not real-time. False positives possible.
|
||||
- **Data Provenance System**: Every data point in the API response now includes
|
||||
a _provenance field: source, validation method (cross-validated / heuristic /
|
||||
computed / single-source), and confidence level (high / medium / experimental).
|
||||
Visible in UI as colour-coded badges: green = validated, orange = indicative.
|
||||
- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude
|
||||
Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report,
|
||||
search_network, get_resilience_score. All responses include provenance metadata.
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
{"d":"2026-03-30","t":"FEAT","m":"Add /api/enrich endpoint: Wikipedia lookup + website meta scraping with redirect following"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"ASPA /api/aspa: 18s hard timeout guard + 8s per-call limit, prevents 504 gateway errors"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"WHOIS: defensive null check + HTML response detection"}
|
||||
{"d":"2026-03-30","t":"UI","m":"Map: replace popup overlays with left side panel"}
|
||||
{"d":"2026-03-30","t":"FEAT","m":"Map: OIM Telecoms fiber layer (OpenInfraMap vector tiles)"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"Map layer toggles: fix source-exists early-return bug for cables/datacenters"}
|
||||
{"d":"2026-03-30","t":"UI","m":"Provider Relationship Graph: fix text colors for light background"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"Network Health: defensive HTML response check, prevent Unexpected token error"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"enrich: skip Wikipedia disambiguation pages, try first-word fallback for compound names"}
|
||||
{"d":"2026-03-30","t":"INFRA","m":"reisekosten.context-x.org DNS CNAME configured + service live on port 3104"}
|
||||
{"d":"2026-03-30","t":"INFRA","m":"PeerCortex repo created and pushed to Gitea (gitea.context-x.org/rene/PeerCortex)"}
|
||||
@ -1,199 +0,0 @@
|
||||
/**
|
||||
* BGP Hijack Monitor
|
||||
*
|
||||
* Background job that monitors own ASNs for unexpected BGP origin changes.
|
||||
* Runs periodically (every 6 hours via systemd timer) to detect potential hijacks.
|
||||
*
|
||||
* Usage:
|
||||
* node bgp-hijack-monitor.js
|
||||
*
|
||||
* Environment Variables:
|
||||
* - DB_HOST: PostgreSQL host (default: 192.168.178.82)
|
||||
* - DB_PORT: PostgreSQL port (default: 5432)
|
||||
* - DB_NAME: Database name (default: llm_gateway)
|
||||
* - DB_USER: Database user (default: llm)
|
||||
* - DB_PASSWORD: Database password (default: llm_secure_2026)
|
||||
* - OWN_ASNS: Comma-separated list of ASNs to monitor (e.g., "13335,15169,32787")
|
||||
* - FINDINGS_TABLE: Table to write findings to (default: findings)
|
||||
*/
|
||||
|
||||
const pg = require('pg');
|
||||
const pool = new pg.Pool({
|
||||
host: process.env.DB_HOST || '192.168.178.82',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'llm_gateway',
|
||||
user: process.env.DB_USER || 'llm',
|
||||
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
||||
max: 5,
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
const OWN_ASNS = (process.env.OWN_ASNS || '13335,15169').split(',').map(a => a.trim());
|
||||
const FINDINGS_TABLE = process.env.FINDINGS_TABLE || 'findings';
|
||||
|
||||
/**
|
||||
* Get all prefixes announced by an ASN from BGP routes table
|
||||
*/
|
||||
async function getBaselinePrefixes(asn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT DISTINCT prefix, origin_asn
|
||||
FROM bgp_routes
|
||||
WHERE origin_asn = $1
|
||||
LIMIT 1000
|
||||
`;
|
||||
const result = await client.query(query, [asn]);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current BGP routes for a set of prefixes
|
||||
*/
|
||||
async function getCurrentRoutes(prefixes) {
|
||||
if (prefixes.length === 0) return [];
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const placeholders = prefixes.map((_, i) => `$${i + 1}`).join(',');
|
||||
const query = `
|
||||
SELECT prefix, origin_asn
|
||||
FROM bgp_routes
|
||||
WHERE prefix = ANY(ARRAY[${placeholders}]::CIDR[])
|
||||
AND last_seen > NOW() - INTERVAL '1 hour'
|
||||
`;
|
||||
const result = await client.query(query, prefixes);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for hijacks: unexpected origin ASNs for known prefixes
|
||||
*/
|
||||
async function checkForHijacks(asn) {
|
||||
console.log(`[BGP Hijack Monitor] Checking ASN ${asn} for unexpected origin changes...`);
|
||||
|
||||
try {
|
||||
// Get baseline (expected) prefixes for this ASN
|
||||
const baseline = await getBaselinePrefixes(asn);
|
||||
if (baseline.length === 0) {
|
||||
console.log(`[BGP Hijack Monitor] No baseline prefixes found for ASN ${asn}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[BGP Hijack Monitor] Baseline: ${baseline.length} prefixes for AS${asn}`);
|
||||
|
||||
// Get current routes for these prefixes
|
||||
const prefixList = baseline.map(r => r.prefix);
|
||||
const current = await getCurrentRoutes(prefixList);
|
||||
|
||||
// Group current routes by prefix
|
||||
const currentByPrefix = {};
|
||||
current.forEach(r => {
|
||||
if (!currentByPrefix[r.prefix]) {
|
||||
currentByPrefix[r.prefix] = [];
|
||||
}
|
||||
currentByPrefix[r.prefix].push(r.origin_asn);
|
||||
});
|
||||
|
||||
// Compare: detect unexpected origin ASNs
|
||||
const hijackCandidates = [];
|
||||
baseline.forEach(expectedRoute => {
|
||||
const prefix = expectedRoute.prefix;
|
||||
const expectedAsn = expectedRoute.origin_asn;
|
||||
|
||||
if (currentByPrefix[prefix]) {
|
||||
const currentAsns = currentByPrefix[prefix];
|
||||
|
||||
// If the expected ASN is NOT in the current set of origin ASNs, it's a hijack
|
||||
if (!currentAsns.includes(expectedAsn)) {
|
||||
hijackCandidates.push({
|
||||
prefix,
|
||||
expectedAsn,
|
||||
foundAsns: currentAsns,
|
||||
});
|
||||
}
|
||||
|
||||
// If there are MULTIPLE origin ASNs (not just the expected one), it's also suspicious (MOAS)
|
||||
if (currentAsns.length > 1) {
|
||||
hijackCandidates.push({
|
||||
prefix,
|
||||
expectedAsn,
|
||||
foundAsns: currentAsns,
|
||||
type: 'MOAS', // Multiple Origin ASNs
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hijackCandidates.length > 0) {
|
||||
console.log(`[BGP Hijack Monitor] ⚠️ DETECTED ${hijackCandidates.length} hijack candidates`);
|
||||
|
||||
// Log each finding to the findings table
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
for (const hijack of hijackCandidates) {
|
||||
const description = hijack.type === 'MOAS'
|
||||
? `MOAS detected: Multiple origin ASNs (${hijack.foundAsns.join(', ')}) announcing ${hijack.prefix}`
|
||||
: `BGP Hijack: AS${hijack.expectedAsn} expected but AS${hijack.foundAsns.join(', AS')} found announcing ${hijack.prefix}`;
|
||||
|
||||
const findingQuery = `
|
||||
INSERT INTO ${FINDINGS_TABLE}
|
||||
(timestamp, source, severity, asn, description, details)
|
||||
VALUES (NOW(), 'bgp_hijack_monitor', 'HIGH', $1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
const details = JSON.stringify({
|
||||
prefix: hijack.prefix,
|
||||
expected_asn: hijack.expectedAsn,
|
||||
found_asns: hijack.foundAsns,
|
||||
type: hijack.type || 'HIJACK',
|
||||
detected_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await client.query(findingQuery, [asn, description, details]);
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} else {
|
||||
console.log(`[BGP Hijack Monitor] ✓ No hijacks detected for AS${asn}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[BGP Hijack Monitor] Error checking ASN ${asn}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main: Check all monitored ASNs
|
||||
*/
|
||||
async function main() {
|
||||
console.log(`[BGP Hijack Monitor] Starting hijack detection for ASNs: ${OWN_ASNS.join(', ')}`);
|
||||
|
||||
for (const asn of OWN_ASNS) {
|
||||
await checkForHijacks(asn);
|
||||
}
|
||||
|
||||
console.log('[BGP Hijack Monitor] Hijack detection complete');
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Error handling
|
||||
process.on('error', (err) => {
|
||||
console.error('[BGP Hijack Monitor] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[BGP Hijack Monitor] Main loop error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# PeerCortex deploy script
|
||||
# Usage: ./deploy/deploy.sh
|
||||
# Requires: gh CLI logged in, SSH access to erik
|
||||
|
||||
set -e
|
||||
REMOTE=erik
|
||||
APP_DIR=/opt/peercortex-app
|
||||
|
||||
echo "[deploy] Pushing to GitHub..."
|
||||
git push origin main
|
||||
|
||||
echo "[deploy] Pulling on ${REMOTE}..."
|
||||
GH_TOKEN=$(gh auth token)
|
||||
GH_USER=$(gh api user --jq .login)
|
||||
ssh ${REMOTE} "cd ${APP_DIR} && \
|
||||
git remote set-url origin https://${GH_USER}:${GH_TOKEN}@github.com/${GH_USER}/PeerCortex.git && \
|
||||
git pull origin main && \
|
||||
git remote set-url origin https://github.com/${GH_USER}/PeerCortex.git && \
|
||||
npm install --omit=dev --silent && \
|
||||
pm2 restart peercortex"
|
||||
|
||||
echo "[deploy] Done."
|
||||
@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex - Network Intelligence Dashboard</title>
|
||||
<script defer src="https://analytics.fichtmueller.org/script.js" data-website-id="1cdd1e46-37f8-47c3-9b7f-a3992a46f5ed"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
1751
deploy/server.js
1751
deploy/server.js
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/search?q=AS13335
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/search?q=1.1.1.0%2F24
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/irr-audit?asn=13335
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/rpki-history?prefix=1.1.1.0%2F24
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/aspath?asn=13335
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/looking-glass?asn=13335
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/ix-matrix?asn=13335
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/hijack-alerts?asn=13335
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/rib/routers
|
||||
@ -1 +0,0 @@
|
||||
GET https://peercortex.org/api/prefix-changes?prefix=1.1.1.0%2F24
|
||||
@ -1,434 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@ -1,273 +0,0 @@
|
||||
/**
|
||||
* MAGATAMA S2 TEN BGP Enrichment Task
|
||||
*
|
||||
* Enriches MAGATAMA S2 TEN (Cloud) findings with:
|
||||
* 1. BGP status (is the IP/prefix announced?)
|
||||
* 2. RPKI validity (is the announcing ASN authorized?)
|
||||
* 3. Threat intelligence (known malicious IP or ASN?)
|
||||
*
|
||||
* This bridges PeerCortex local intelligence to MAGATAMA security findings.
|
||||
* Called when S2 TEN detects anomalous external traffic.
|
||||
*/
|
||||
|
||||
const pg = require('pg');
|
||||
|
||||
// Initialize PostgreSQL connection pool
|
||||
const pool = new pg.Pool({
|
||||
host: process.env.DB_HOST || '192.168.178.82',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'llm_gateway',
|
||||
user: process.env.DB_USER || 'llm',
|
||||
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get BGP status for an IP address
|
||||
*/
|
||||
async function getBgpStatus(ipAddress) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
prefix,
|
||||
origin_asn,
|
||||
ARRAY_AGG(DISTINCT origin_asn) as origin_asns,
|
||||
visibility_percent,
|
||||
last_seen
|
||||
FROM bgp_routes
|
||||
WHERE prefix >> $1::inet
|
||||
ORDER BY prefix DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await client.query(query, [ipAddress]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate RPKI for a prefix + origin ASN pair
|
||||
*/
|
||||
async function validateRpki(prefix, originAsn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
status,
|
||||
details
|
||||
FROM rpki_roas
|
||||
WHERE prefix >> $1::inet
|
||||
AND origin_asn = $2
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await client.query(query, [prefix, originAsn]);
|
||||
if (result.rows.length > 0) {
|
||||
return {
|
||||
status: result.rows[0].status || 'valid',
|
||||
details: result.rows[0].details,
|
||||
};
|
||||
}
|
||||
// No ROA found = not-found
|
||||
return { status: 'not-found' };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat intelligence for an IP address
|
||||
*/
|
||||
async function getThreatIntel(ipAddress) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
ip_address,
|
||||
threat_level,
|
||||
confidence_score,
|
||||
source,
|
||||
cached_at
|
||||
FROM threat_intel
|
||||
WHERE ip_address = $1::inet
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await client.query(query, [ipAddress]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine kill-chain phase based on BGP + RPKI + Threat signals
|
||||
*/
|
||||
function determineKillChainPhase(enriched) {
|
||||
const flags = enriched.security_flags || [];
|
||||
|
||||
if (flags.some(f => f.flag === 'RPKI_INVALID')) {
|
||||
return 'WEAPONIZATION'; // BGP hijack = active attack infrastructure setup
|
||||
}
|
||||
|
||||
if (flags.some(f => f.flag === 'MALICIOUS_IP')) {
|
||||
return 'DELIVERY'; // Known malicious IP attempting connection
|
||||
}
|
||||
|
||||
if (enriched.bgp_intel.announced === false) {
|
||||
return 'EXPLOITATION'; // Unknown IP not in routing table = potentially spoofed
|
||||
}
|
||||
|
||||
return 'RECONNAISSANCE'; // Announced legitimate IP but with anomalous traffic pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* Map to MITRE ATT&CK framework
|
||||
*/
|
||||
function mapToMitreAttack(enriched) {
|
||||
const mapping = [];
|
||||
|
||||
if (enriched.bgp_intel.rpki_valid === false) {
|
||||
mapping.push({
|
||||
technique: 'T1040', // Network Sniffing (implicit in BGP hijack)
|
||||
tactic: 'Credential Access / Collection',
|
||||
description: 'BGP RPKI validation failed - possible route hijack',
|
||||
});
|
||||
}
|
||||
|
||||
if (enriched.threat_intel?.threat_level === 'CRITICAL') {
|
||||
mapping.push({
|
||||
technique: 'T1566', // Phishing (network variant)
|
||||
tactic: 'Initial Access',
|
||||
description: 'Connection from known malicious IP',
|
||||
});
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a finding with BGP + RPKI + Threat data
|
||||
* @param {Object} finding - MAGATAMA finding { ip_address, port, protocol, ... }
|
||||
* @returns {Object} Enriched finding with bgp_status, rpki_valid, threat_level
|
||||
*/
|
||||
async function enrichFindingWithBGPIntel(finding) {
|
||||
const enriched = { ...finding, bgp_intel: {} };
|
||||
const ipAddress = finding.ip_address;
|
||||
|
||||
if (!ipAddress) {
|
||||
console.log('[S2TEN Enrichment] No IP address in finding, skipping enrichment');
|
||||
return enriched;
|
||||
}
|
||||
|
||||
try {
|
||||
// ---- Step 1: BGP Status Lookup ----
|
||||
// Check if this IP is part of an announced prefix in our local BGP routes
|
||||
const bgpStatus = await getBgpStatus(ipAddress);
|
||||
if (bgpStatus) {
|
||||
enriched.bgp_intel.announced = true;
|
||||
enriched.bgp_intel.prefix = bgpStatus.prefix;
|
||||
enriched.bgp_intel.origin_asn = bgpStatus.origin_asns ? bgpStatus.origin_asns[0] : null;
|
||||
enriched.bgp_intel.origin_asns = bgpStatus.origin_asns || [];
|
||||
enriched.bgp_intel.visibility_percent = bgpStatus.visibility_percent;
|
||||
enriched.bgp_intel.last_seen = bgpStatus.last_seen;
|
||||
|
||||
// ---- Step 2: RPKI Validity Check ----
|
||||
// If we know the origin ASN, validate RPKI
|
||||
if (enriched.bgp_intel.origin_asn) {
|
||||
try {
|
||||
const rpkiResult = await validateRpki(
|
||||
bgpStatus.prefix || ipAddress,
|
||||
enriched.bgp_intel.origin_asn
|
||||
);
|
||||
enriched.bgp_intel.rpki_status = rpkiResult.status;
|
||||
enriched.bgp_intel.rpki_valid = rpkiResult.status === 'valid';
|
||||
|
||||
// Alert if RPKI is INVALID (potential hijack!)
|
||||
if (rpkiResult.status === 'invalid') {
|
||||
enriched.security_flags = enriched.security_flags || [];
|
||||
enriched.security_flags.push({
|
||||
flag: 'RPKI_INVALID',
|
||||
severity: 'CRITICAL',
|
||||
message: `RPKI validation failed: AS${enriched.bgp_intel.origin_asn} is not authorized to announce this prefix`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[S2TEN] RPKI check failed for ${ipAddress}:`, e.message);
|
||||
enriched.bgp_intel.rpki_status = 'unknown';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enriched.bgp_intel.announced = false;
|
||||
enriched.bgp_intel.message = 'IP not found in BGP routing table';
|
||||
}
|
||||
|
||||
// ---- Step 3: Threat Intelligence Lookup ----
|
||||
// Check if this IP or its origin ASN is in threat_intel table
|
||||
const threatIntel = await getThreatIntel(ipAddress);
|
||||
if (threatIntel) {
|
||||
enriched.threat_intel = {
|
||||
ip_address: threatIntel.ip_address,
|
||||
threat_level: threatIntel.threat_level,
|
||||
confidence_score: threatIntel.confidence_score,
|
||||
source: threatIntel.source,
|
||||
cached_at: threatIntel.cached_at,
|
||||
};
|
||||
|
||||
// Alert if threat level is HIGH or CRITICAL
|
||||
if (['HIGH', 'CRITICAL'].includes(threatIntel.threat_level)) {
|
||||
enriched.security_flags = enriched.security_flags || [];
|
||||
enriched.security_flags.push({
|
||||
flag: 'MALICIOUS_IP',
|
||||
severity: threatIntel.threat_level,
|
||||
message: `Known malicious IP: threat level ${threatIntel.threat_level} (confidence: ${threatIntel.confidence_score}%)`,
|
||||
source: threatIntel.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 4: MAGATAMA Kill-Chain Correlation ----
|
||||
// Map BGP + RPKI + Threat findings to MITRE ATT&CK kill chain
|
||||
enriched.kill_chain_phase = determineKillChainPhase(enriched);
|
||||
enriched.mitre_attack_mapping = mapToMitreAttack(enriched);
|
||||
|
||||
return enriched;
|
||||
} catch (e) {
|
||||
console.error(`[S2TEN Enrichment] Fatal error enriching ${ipAddress}:`, e.message);
|
||||
enriched.enrichment_error = e.message;
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch enrich multiple findings
|
||||
*/
|
||||
async function enrichFindings(findings) {
|
||||
console.log(`[S2TEN Enrichment] Enriching ${findings.length} findings...`);
|
||||
const enriched = [];
|
||||
|
||||
for (const finding of findings) {
|
||||
try {
|
||||
const result = await enrichFindingWithBGPIntel(finding);
|
||||
enriched.push(result);
|
||||
} catch (e) {
|
||||
console.error(`[S2TEN Enrichment] Error enriching finding:`, e.message);
|
||||
enriched.push({ ...finding, enrichment_error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enrichFindingWithBGPIntel,
|
||||
enrichFindings,
|
||||
determineKillChainPhase,
|
||||
mapToMitreAttack,
|
||||
pool,
|
||||
getBgpStatus,
|
||||
validateRpki,
|
||||
getThreatIntel,
|
||||
};
|
||||
990
package-lock.json
generated
990
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "peercortex",
|
||||
"version": "0.7.0",
|
||||
"version": "0.6.5",
|
||||
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
||||
"main": "dist/mcp-server/index.js",
|
||||
"types": "dist/mcp-server/index.d.ts",
|
||||
@ -66,29 +66,19 @@
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"axios": "^1.6.0",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"fastify": "^5.8.5",
|
||||
"joi": "^17.11.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-whois": "^2.1.3",
|
||||
"ollama": "^0.5.12",
|
||||
"pg": "^8.11.0",
|
||||
"playwright": "^1.40.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/node-cron": "^3.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"eslint": "^9.16.0",
|
||||
"nock": "^13.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
||||
@ -1,502 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ASPA Adoption Tracker - PeerCortex</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.change-up {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.change-down {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.region-table,
|
||||
.ixp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.region-table th,
|
||||
.ixp-table th {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.region-table td,
|
||||
.ixp-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.region-table tr:hover,
|
||||
.ixp-table tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.coverage-bar {
|
||||
background: #e5e7eb;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coverage-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.forecast-box {
|
||||
background: #f0f9ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.forecast-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.forecast-label {
|
||||
font-size: 12px;
|
||||
color: #1e40af;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
display: inline-block;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🌐 ASPA Adoption Tracker</h1>
|
||||
<p class="subtitle">Global BGP ASPA (Autonomous System Provider Authorization) adoption trends</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Current Coverage</div>
|
||||
<div class="stat-value" id="current-coverage">-</div>
|
||||
<div class="stat-change">
|
||||
<span id="change-24h" class="change-up">+0.5%</span> (24h)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Trend</div>
|
||||
<div class="stat-value" id="trend-indicator">↑</div>
|
||||
<div class="stat-change" id="trend-strength">Moderate growth</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">6-Month Forecast</div>
|
||||
<div class="stat-value" id="forecast-coverage">-</div>
|
||||
<div class="stat-change">
|
||||
<span id="forecast-confidence">-</span> confidence
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Data Points</div>
|
||||
<div class="stat-value" id="data-points">-</div>
|
||||
<div class="stat-change" id="last-update">Updated -</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">📈 Adoption Trend (30 days)</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="adoption-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">🗺️ Regional Coverage</div>
|
||||
<table class="region-table" id="regions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Coverage</th>
|
||||
<th>ASNs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="regions-body">
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: #999">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">🏢 IXP Coverage</div>
|
||||
<table class="ixp-table" id="ixps-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IXP</th>
|
||||
<th>Coverage</th>
|
||||
<th>Participants</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ixps-body">
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: #999">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">🎯 Top Adopters</div>
|
||||
<div style="margin-top: 15px" id="top-adopters-list">
|
||||
<p style="color: #999">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Last updated: <span id="footer-timestamp">-</span></p>
|
||||
<p style="margin-top: 10px; color: #999">Data sources: RIPE Stat, PeeringDB, CAIDA. Updated daily at 2:00 AM UTC.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let adoptionChart = null
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const response = await fetch('/api/aspa-adoption-stats?period=30d')
|
||||
if (!response.ok) throw new Error('Failed to load data')
|
||||
const stats = await response.json()
|
||||
|
||||
updateStats(stats)
|
||||
renderAdoptionChart(stats.trend)
|
||||
loadRegionalData()
|
||||
loadIXPData()
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
document.body.innerHTML += `<div class="error">Failed to load ASPA adoption data</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(stats) {
|
||||
document.getElementById('current-coverage').textContent = stats.current.coverage.toFixed(1) + '%'
|
||||
document.getElementById('change-24h').textContent =
|
||||
(stats.current.change24h >= 0 ? '+' : '') + stats.current.change24h.toFixed(2) + '%'
|
||||
document.getElementById('change-24h').className = stats.current.change24h >= 0 ? 'change-up' : 'change-down'
|
||||
|
||||
const trendSymbols = { up: '↑', down: '↓', stable: '→' }
|
||||
document.getElementById('trend-indicator').textContent = trendSymbols[stats.current.trend] || '→'
|
||||
document.getElementById('trend-strength').textContent =
|
||||
stats.current.trend.charAt(0).toUpperCase() + stats.current.trend.slice(1) + ' trend'
|
||||
|
||||
document.getElementById('forecast-coverage').textContent = stats.forecast.predictedCoverage6m.toFixed(1) + '%'
|
||||
document.getElementById('forecast-confidence').textContent = (stats.forecast.confidence * 100).toFixed(0) + '%'
|
||||
|
||||
document.getElementById('data-points').textContent = stats.trend.length
|
||||
const lastUpdate = new Date()
|
||||
document.getElementById('last-update').textContent = 'Updated ' + formatTime(lastUpdate)
|
||||
document.getElementById('footer-timestamp').textContent = lastUpdate.toLocaleString()
|
||||
|
||||
renderTopAdopters(stats.topAdopters)
|
||||
}
|
||||
|
||||
function renderAdoptionChart(trendData) {
|
||||
const ctx = document.getElementById('adoption-chart').getContext('2d')
|
||||
|
||||
if (adoptionChart) {
|
||||
adoptionChart.destroy()
|
||||
}
|
||||
|
||||
adoptionChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map((p) => p.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'ASPA Coverage (%)',
|
||||
data: trendData.map((p) => p.coveragePercentage),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#667eea',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { callback: (v) => v + '%' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRegionalData() {
|
||||
try {
|
||||
const response = await fetch('/api/aspa-adoption-stats/regional')
|
||||
if (!response.ok) throw new Error('Failed to load regional data')
|
||||
const data = await response.json()
|
||||
|
||||
const tbody = document.getElementById('regions-body')
|
||||
tbody.innerHTML = data.regions
|
||||
.map(
|
||||
(r) => `
|
||||
<tr>
|
||||
<td>${r.region}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div style="flex: 1;">
|
||||
<div class="coverage-bar">
|
||||
<div class="coverage-fill" style="width: ${r.coveragePercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span>${r.coveragePercentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${r.ASNsWithASPA} / ${r.totalASNs}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
} catch (error) {
|
||||
console.error('Error loading regional data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIXPData() {
|
||||
try {
|
||||
const response = await fetch('/api/aspa-adoption-stats/ixps?top=5')
|
||||
if (!response.ok) throw new Error('Failed to load IXP data')
|
||||
const data = await response.json()
|
||||
|
||||
const tbody = document.getElementById('ixps-body')
|
||||
tbody.innerHTML = data.ixps
|
||||
.map(
|
||||
(i) => `
|
||||
<tr>
|
||||
<td>${i.ixpName}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div style="flex: 1;">
|
||||
<div class="coverage-bar">
|
||||
<div class="coverage-fill" style="width: ${i.coveragePercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span>${i.coveragePercentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${i.participantsWithASPA} / ${i.participants}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
} catch (error) {
|
||||
console.error('Error loading IXP data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopAdopters(adopters) {
|
||||
const list = document.getElementById('top-adopters-list')
|
||||
list.innerHTML = adopters
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(a, i) => `
|
||||
<div style="padding: 10px 0; border-bottom: 1px solid #eee;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 600;">
|
||||
<span style="color: #667eea; margin-right: 8px;">#${i + 1}</span>
|
||||
${a.name}
|
||||
</span>
|
||||
<span style="color: #666; font-size: 12px;">${a.providers} provider${a.providers !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadData)
|
||||
setInterval(loadData, 5 * 60 * 1000)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -4,8 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex — The ASN News</title>
|
||||
<link rel="canonical" href="https://peercortex.org">
|
||||
<script defer src="https://analytics.fichtmueller.org/script.js" data-website-id="1cdd1e46-37f8-47c3-9b7f-a3992a46f5ed"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,800;0,900;1,400&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
@ -48,10 +46,6 @@ a:hover{color:var(--purple)}
|
||||
.ed-tagline{font-family:var(--body);font-size:.8rem;font-style:italic;color:var(--muted);letter-spacing:.02em;margin-top:.15rem}
|
||||
.ed-logo sup{font-size:.8rem;color:var(--purple);font-family:var(--mono);font-weight:700;vertical-align:super}
|
||||
.ed-masthead-meta{font-family:var(--mono);font-size:.65rem;color:var(--muted);text-align:right;line-height:1.6}
|
||||
.ed-masthead-meta a{color:var(--blue);text-decoration:underline;text-underline-offset:2px;opacity:.75}
|
||||
.ed-masthead-meta a:hover{opacity:1;color:var(--purple)}
|
||||
.bmac-btn{display:inline-block;padding:.2rem .55rem;border:1px solid var(--orange);color:var(--orange)!important;font-family:var(--mono);font-size:.65rem;font-weight:600;text-decoration:none!important;line-height:1.4;letter-spacing:.03em;opacity:1!important;transition:background .15s,color .15s}
|
||||
.bmac-btn:hover{background:var(--orange);color:var(--bg)!important}
|
||||
.ed-rule-h{border:none;border-top:2px solid var(--text);margin:0}
|
||||
.ed-rule{border:none;border-top:1px solid var(--border);margin:0}
|
||||
.ed-nav{display:flex;gap:1.75rem;padding:.6rem 0;flex-wrap:wrap}
|
||||
@ -429,7 +423,7 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
<div class="ed-logo">PeerCortex<sup>β</sup></div>
|
||||
<div class="ed-tagline">The ASN News</div>
|
||||
</div>
|
||||
<div class="ed-masthead-meta">The ASN News<br><span style="font-family:var(--mono)">peercortex.org · v0.6.9 · routing intelligence</span><br><span style="font-family:var(--mono);font-size:.6rem"><a id="ghMetaLink" href="#" target="_blank">GitHub</a> · <a href="#" onclick="openChangelog();return false">Changelog</a> · <a href="https://blog.fichtmueller.org" target="_blank">Blog</a> · <a href="https://buymeacoffee.com/fimue" target="_blank" class="bmac-btn">☕ Buy me a coffee</a></span></div>
|
||||
<div class="ed-masthead-meta">The ASN News<br><span style="font-family:var(--mono)">peercortex.org · v0.6.9 · routing intelligence</span></div>
|
||||
</div>
|
||||
<hr class="ed-rule-h">
|
||||
<nav class="ed-nav">
|
||||
@ -439,6 +433,8 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
<a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a>
|
||||
<a href="https://www.routeviews.org" target="_blank">Route Views</a>
|
||||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a>
|
||||
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">GitHub</a>
|
||||
<a href="#" onclick="openChangelog();return false">Changelog</a>
|
||||
<span class="share-dropdown" id="shareDropdown">
|
||||
<a href="#" onclick="toggleShareMenu();return false" id="shareNavLink" title="Share" style="display:flex;align-items:center;gap:.35rem">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
@ -737,36 +733,6 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
<div id="hijackContent"></div>
|
||||
</section>
|
||||
|
||||
<!-- Prefix Changes -->
|
||||
<section class="card hidden" id="pfxChangesCard" title="Prefix Changes — BGP announcements, withdrawals, origin-ASN changes, RPKI status issues, and live RIS stream for a custom time window">
|
||||
<div class="card-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Prefix Changes
|
||||
</div>
|
||||
<!-- Time range picker -->
|
||||
<div id="pfxTimeRange" style="display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin-bottom:.75rem">
|
||||
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-right:.2rem">RANGE:</span>
|
||||
<button class="pfx-preset active" onclick="pfxSetPreset(1)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">1h</button>
|
||||
<button class="pfx-preset" onclick="pfxSetPreset(6)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">6h</button>
|
||||
<button class="pfx-preset" onclick="pfxSetPreset(24)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">24h</button>
|
||||
<button class="pfx-preset" onclick="pfxSetPreset(168)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">7d</button>
|
||||
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-left:.4rem">CUSTOM:</span>
|
||||
<input type="datetime-local" id="pfxFrom" style="font-family:var(--mono);font-size:.65rem;background:transparent;border:1px solid var(--border);color:var(--text);padding:.2rem .4rem;outline:none">
|
||||
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim)">→</span>
|
||||
<input type="datetime-local" id="pfxTo" style="font-family:var(--mono);font-size:.65rem;background:transparent;border:1px solid var(--border);color:var(--text);padding:.2rem .4rem;outline:none">
|
||||
<button onclick="pfxLoadCustom()" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .7rem;background:var(--text);color:var(--bg);border:none;cursor:pointer;letter-spacing:.05em">LOAD</button>
|
||||
</div>
|
||||
<!-- Tabs -->
|
||||
<div style="display:flex;gap:0;margin-bottom:.75rem;border-bottom:1px solid var(--border)">
|
||||
<button class="pfx-tab active" onclick="pfxTab('ann')" id="pfxTabAnn" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid var(--text);background:transparent;color:var(--text);cursor:pointer">📢 Announced <span id="pfxCntAnn" style="color:var(--dim)"></span></button>
|
||||
<button class="pfx-tab" onclick="pfxTab('wd')" id="pfxTabWd" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">📤 Withdrawn <span id="pfxCntWd" style="color:var(--dim)"></span></button>
|
||||
<button class="pfx-tab" onclick="pfxTab('orig')" id="pfxTabOrig" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🔄 Origin Changes <span id="pfxCntOrig" style="color:var(--dim)"></span></button>
|
||||
<button class="pfx-tab" onclick="pfxTab('rpki')" id="pfxTabRpki" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🛡 RPKI Issues <span id="pfxCntRpki" style="color:var(--dim)"></span></button>
|
||||
<button class="pfx-tab" onclick="pfxTab('live')" id="pfxTabLive" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🔴 Live</button>
|
||||
</div>
|
||||
<div id="pfxContent" style="font-family:var(--mono);font-size:.75rem"></div>
|
||||
</section>
|
||||
|
||||
<!-- AS-SET Expander -->
|
||||
<section class="card hidden" id="assetCard" title="AS-SET expander — recursively resolves an IRR AS-SET (e.g. AS-EXAMPLE) to the full list of member ASNs. Useful for validating import/export filters in router configs">
|
||||
<div class="card-title">
|
||||
@ -1166,11 +1132,8 @@ async function doLookup() {
|
||||
$('skeleton').classList.remove('hidden');
|
||||
$('metaBar').textContent = '';
|
||||
|
||||
const lookupCtrl = new AbortController();
|
||||
const lookupTimer = setTimeout(() => lookupCtrl.abort(), 15000);
|
||||
try {
|
||||
const resp = await fetch('/api/lookup?asn=' + raw, { signal: lookupCtrl.signal });
|
||||
clearTimeout(lookupTimer);
|
||||
const resp = await fetch('/api/lookup?asn=' + raw);
|
||||
const d = await resp.json();
|
||||
|
||||
if (d.error) {
|
||||
@ -1207,9 +1170,8 @@ async function doLookup() {
|
||||
// v0.6.1 new features
|
||||
loadNewFeatures(raw);
|
||||
} catch (e) {
|
||||
clearTimeout(lookupTimer);
|
||||
$('skeleton').classList.add('hidden');
|
||||
$('metaBar').textContent = e.name === 'AbortError' ? 'Lookup timed out — try again' : 'Error: ' + e.message;
|
||||
$('metaBar').textContent = 'Error: ' + e.message;
|
||||
} finally {
|
||||
$('searchBtn').disabled = false;
|
||||
$('searchBtn').textContent = 'Lookup';
|
||||
@ -1218,11 +1180,8 @@ async function doLookup() {
|
||||
|
||||
async function loadAspaData(asn) {
|
||||
$('aspaContent').innerHTML = '<div class="section-loading">Loading ASPA data...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
try {
|
||||
const resp = await fetch('/api/aspa?asn=' + asn, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
const resp = await fetch('/api/aspa?asn=' + asn);
|
||||
if (!resp.ok) { $('aspaContent').textContent = 'ASPA data unavailable (server ' + resp.status + ')'; renderProviderGraphFromLookupFallback(asn); return; }
|
||||
var text = await resp.text();
|
||||
if (!text || text[0] === '<') { $('aspaContent').textContent = 'ASPA data unavailable (timeout). Provider data shown from lookup.'; renderProviderGraphFromLookupFallback(asn); return; }
|
||||
@ -1230,8 +1189,7 @@ async function loadAspaData(asn) {
|
||||
if (d.error) { $('aspaContent').textContent = 'ASPA check failed: ' + d.error; renderProviderGraphFromLookupFallback(asn); return; }
|
||||
renderAspa(d);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
$('aspaContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">ASPA data temporarily unavailable</div>';
|
||||
$('aspaContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">ASPA check failed: ' + escHtml(e.message) + '</div>';
|
||||
renderProviderGraphFromLookupFallback(asn);
|
||||
}
|
||||
}
|
||||
@ -1248,20 +1206,16 @@ function renderProviderGraphFromLookupFallback(asn) {
|
||||
|
||||
async function loadBgroutesData(asn) {
|
||||
$('bgroutesContent').innerHTML = '<div class="section-loading">Loading bgproutes.io data...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 12000);
|
||||
try {
|
||||
const resp = await fetch('/api/bgproutes?asn=' + asn, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
const resp = await fetch('/api/bgproutes?asn=' + asn);
|
||||
const d = await resp.json();
|
||||
if (d.error) {
|
||||
$('bgroutesContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">bgproutes.io data temporarily unavailable</div>';
|
||||
$('bgroutesContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">bgproutes.io query failed: ' + escHtml(d.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
renderBgroutes(d);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
$('bgroutesContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">bgproutes.io data temporarily unavailable</div>';
|
||||
$('bgroutesContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">bgproutes.io query failed: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@ -2082,11 +2036,8 @@ function escAttr(s) {
|
||||
|
||||
async function loadAspaVerifyData(asn) {
|
||||
$('aspaDeepContent').innerHTML = '<div class="section-loading">Running RFC-compliant ASPA verification...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||||
try {
|
||||
const resp = await fetch('/api/aspa/verify?asn=' + asn, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
const resp = await fetch('/api/aspa/verify?asn=' + asn);
|
||||
if (!resp.ok) { $('aspaDeepContent').textContent = 'ASPA verification unavailable (server ' + resp.status + ')'; return; }
|
||||
var text = await resp.text();
|
||||
if (!text || text[0] === '<') { $('aspaDeepContent').textContent = 'ASPA verification unavailable (timeout for large ASNs)'; return; }
|
||||
@ -2094,8 +2045,7 @@ async function loadAspaVerifyData(asn) {
|
||||
if (d.error) { $('aspaDeepContent').textContent = 'ASPA verification failed: ' + d.error; return; }
|
||||
renderAspaDeep(d);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
$('aspaDeepContent').textContent = 'ASPA verification temporarily unavailable';
|
||||
$('aspaDeepContent').textContent = 'ASPA verification failed: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2857,11 +2807,8 @@ function renderIxTrafficStats(ixConnections) {
|
||||
// ============================================================
|
||||
async function loadWhoisData(asn) {
|
||||
$('whoisContent').innerHTML = '<div class="section-loading">Loading WHOIS data...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
try {
|
||||
var resp = await fetch('/api/whois?resource=AS' + asn, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
var resp = await fetch('/api/whois?resource=AS' + asn);
|
||||
if (!resp.ok) { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS unavailable (server ' + resp.status + ')</div>'; return; }
|
||||
var text = await resp.text();
|
||||
if (!text || text[0] === '<') { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>'; return; }
|
||||
@ -2869,8 +2816,7 @@ async function loadWhoisData(asn) {
|
||||
if (d.error) { $('whoisContent').innerHTML = '<div style="color:var(--orange);font-size:.85rem">WHOIS: ' + escHtml(d.error) + '</div>'; return; }
|
||||
renderWhois(d);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
$('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>';
|
||||
$('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS lookup failed: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@ -2961,11 +2907,8 @@ async function loadOverviewEnrichment(asn, name, website) {
|
||||
|
||||
async function loadHealthReport(asn) {
|
||||
$('healthContent').innerHTML = '<div class="section-loading">Running comprehensive validation (13 checks)...</div>';
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||||
try {
|
||||
var resp = await fetch('/api/validate?asn=' + asn, { signal: ctrl.signal });
|
||||
clearTimeout(timer);
|
||||
var resp = await fetch('/api/validate?asn=' + asn);
|
||||
if (!resp.ok) { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report unavailable (server ' + resp.status + ')</div>'; return; }
|
||||
var text = await resp.text();
|
||||
if (!text || text[0] === '<') { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>'; return; }
|
||||
@ -2976,7 +2919,6 @@ async function loadHealthReport(asn) {
|
||||
}
|
||||
renderHealthReport(d);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
$('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>';
|
||||
}
|
||||
}
|
||||
@ -3191,9 +3133,9 @@ function renderHealthReport(d) {
|
||||
h += '</div></div></div>';
|
||||
|
||||
// === DATA ACCURACY SECTION ===
|
||||
h += '<div style="margin:1.5rem 0;padding:1rem;background:transparent;border:1px solid var(--border);border-radius:0">';
|
||||
h += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--blue)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>';
|
||||
h += '<span style="font-size:.85rem;font-weight:600;color:var(--blue)">Score Breakdown — Why ' + score + '/100?</span></div>';
|
||||
h += '<div style="margin:1.5rem 0;padding:1rem;background:rgba(122,162,247,.06);border:1px solid rgba(122,162,247,.15);border-radius:10px">';
|
||||
h += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#7aa2f7" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>';
|
||||
h += '<span style="font-size:.85rem;font-weight:600;color:#7aa2f7">Score Breakdown — Why ' + score + '/100?</span></div>';
|
||||
|
||||
// Score calculation table
|
||||
h += '<table style="width:100%;font-size:.78rem;border-collapse:collapse">';
|
||||
@ -3220,7 +3162,7 @@ function renderHealthReport(d) {
|
||||
totalE += earned;
|
||||
}
|
||||
var statusIcon = v.status === 'pass' ? '✅' : v.status === 'warning' ? '⚠️' : v.status === 'fail' ? '❌' : 'ℹ️';
|
||||
h += '<tr style="border-bottom:1px solid var(--border)"><td style="padding:.35rem .5rem">' + statusIcon + ' ' + info.label + '</td>';
|
||||
h += '<tr style="border-bottom:1px solid rgba(255,255,255,.03)"><td style="padding:.35rem .5rem">' + statusIcon + ' ' + info.label + '</td>';
|
||||
h += '<td style="text-align:center;padding:.35rem;color:var(--muted)">' + (v.status === 'info' ? '—' : w) + '</td>';
|
||||
h += '<td style="text-align:center;padding:.35rem;font-weight:600;color:' + (earned === w ? 'var(--green)' : earned > 0 ? 'var(--orange)' : v.status === 'info' ? 'var(--muted)' : 'var(--red)') + '">' + (v.status === 'info' ? '—' : earned) + '</td>';
|
||||
h += '<td style="padding:.35rem .5rem;font-size:.72rem">' + reason + '</td></tr>';
|
||||
@ -3234,7 +3176,7 @@ function renderHealthReport(d) {
|
||||
h += '</table>';
|
||||
|
||||
// Data source note
|
||||
h += '<div style="margin-top:.75rem;padding-top:.6rem;border-top:1px solid var(--border);font-size:.72rem;color:var(--muted)">';
|
||||
h += '<div style="margin-top:.75rem;padding-top:.6rem;border-top:1px solid rgba(255,255,255,.05);font-size:.72rem;color:var(--muted)">';
|
||||
h += '<strong>Data Sources:</strong> PeeringDB (profile, IX, facilities), RIPE Stat (prefixes, neighbours, visibility, RPKI), ';
|
||||
h += 'RIPE Atlas (probes), Cloudflare RPKI (ROA + ASPA), MANRS Observatory, RIPE DB (IRR objects).<br>';
|
||||
h += '<strong>Scoring:</strong> Each check has a weight reflecting its importance to routing security. ';
|
||||
@ -3335,11 +3277,11 @@ function loadPeeringRecommendations(asn, ixConnections, lookupData) {
|
||||
|
||||
$('peeringRecContent').innerHTML = '<div style="color:var(--dim);font-size:.85rem">Checking peering potential with top 20 networks...</div>';
|
||||
|
||||
// Fetch IX presence for top networks via lightweight quick-ix endpoint (1h cached)
|
||||
// Fetch IX presence for top networks
|
||||
Promise.all(topNets.map(function(targetAsn) {
|
||||
return fetch('/api/quick-ix?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
|
||||
var name = d.name || ('AS' + targetAsn);
|
||||
var theirIx = d.ix_connections || [];
|
||||
return fetch('/api/lookup?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
|
||||
var name = d.network ? d.network.name : 'AS' + targetAsn;
|
||||
var theirIx = (d.ix_presence && d.ix_presence.connections) || [];
|
||||
var theirIxIds = new Set(theirIx.map(function(ix) { return ix.ix_id; }));
|
||||
var common = [];
|
||||
myIxIds.forEach(function(id) { if (theirIxIds.has(id)) common.push(myIxNames[id] || 'IX-' + id); });
|
||||
@ -3678,9 +3620,8 @@ async function loadCommunities(asn) {
|
||||
const content = document.getElementById('commContent');
|
||||
card.classList.remove('hidden');
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Decoding communities…</span>';
|
||||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||||
try {
|
||||
const r = await fetch('/api/communities?asn=' + asn, { signal: ctrl.signal });
|
||||
const r = await fetch('/api/communities?asn=' + asn);
|
||||
const d = await r.json();
|
||||
if (!d.communities || !d.communities.length) {
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No communities found for this ASN.</span>';
|
||||
@ -3717,9 +3658,8 @@ async function loadIrrAudit(asn) {
|
||||
const content = document.getElementById('irrContent');
|
||||
card.classList.remove('hidden');
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Checking IRR registration via NLNOG IRR Explorer…</span>';
|
||||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||||
try {
|
||||
const r = await fetch('/api/irr-audit?asn=' + asn, { signal: ctrl.signal });
|
||||
const r = await fetch('/api/irr-audit?asn=' + asn);
|
||||
const d = await r.json();
|
||||
const pct = d.score || 0;
|
||||
const color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--orange)' : 'var(--red)';
|
||||
@ -3767,9 +3707,8 @@ async function loadRpkiHistory(asn) {
|
||||
const content = document.getElementById('rpkiHistContent');
|
||||
card.classList.remove('hidden');
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Loading routing history…</span>';
|
||||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||||
try {
|
||||
const r = await fetch('/api/rpki-history?asn=' + asn, { signal: ctrl.signal });
|
||||
const r = await fetch('/api/rpki-history?asn=' + asn);
|
||||
const d = await r.json();
|
||||
if (!d.prefixes || !d.prefixes.length) {
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No routing history data available for this ASN.</span>';
|
||||
@ -3796,9 +3735,8 @@ async function loadAspath(asn) {
|
||||
const content = document.getElementById('aspathContent');
|
||||
card.classList.remove('hidden');
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Loading AS-PATH data…</span>';
|
||||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 10000);
|
||||
try {
|
||||
const r = await fetch('/api/aspath?asn=' + asn, { signal: ctrl.signal });
|
||||
const r = await fetch('/api/aspath?asn=' + asn);
|
||||
const d = await r.json();
|
||||
const paths = d && d.paths || [];
|
||||
if (!paths.length) { content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No AS-PATH data available for this ASN.</span>'; return; }
|
||||
@ -3919,9 +3857,8 @@ async function loadHijackMonitor(asn) {
|
||||
const content = document.getElementById('hijackContent');
|
||||
card.classList.remove('hidden');
|
||||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Checking hijack status…</span>';
|
||||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||||
try {
|
||||
const r = await fetch('/api/hijack-alerts?asn=' + asn, { signal: ctrl.signal });
|
||||
const r = await fetch('/api/hijack-alerts?asn=' + asn);
|
||||
const d = await r.json();
|
||||
let html = '';
|
||||
if (!d.monitoring) {
|
||||
@ -3966,13 +3903,6 @@ function loadNewFeatures(asn) {
|
||||
loadRpkiHistory(asn);
|
||||
loadAspath(asn);
|
||||
loadHijackMonitor(asn);
|
||||
// init time range to last 1h on first load, then load prefix changes
|
||||
if (!document.getElementById('pfxFrom').value) {
|
||||
const to = new Date(); const from = new Date(Date.now() - 3600000);
|
||||
document.getElementById('pfxFrom').value = from.toISOString().slice(0,16);
|
||||
document.getElementById('pfxTo').value = to.toISOString().slice(0,16);
|
||||
}
|
||||
pfxLoad(asn);
|
||||
// IXP picker: read from ix_presence.connections (the actual API response structure)
|
||||
setTimeout(() => {
|
||||
const raw = currentLookupData || {};
|
||||
@ -3995,172 +3925,6 @@ fetch('/api/visitors').then(r=>r.json()).then(d=>{
|
||||
}).catch(()=>{});
|
||||
|
||||
|
||||
// ── Prefix Changes ─────────────────────────────────────────────
|
||||
let pfxCurrentAsn = null;
|
||||
let pfxCurrentData = null;
|
||||
let pfxActiveTab = 'ann';
|
||||
let pfxLiveWs = null;
|
||||
let pfxLiveLines = [];
|
||||
|
||||
function pfxSetPreset(hours) {
|
||||
document.querySelectorAll('.pfx-preset').forEach(b => b.style.borderColor = 'var(--border)');
|
||||
event.target.style.borderColor = 'var(--text)';
|
||||
const to = new Date();
|
||||
const from = new Date(Date.now() - hours * 3600000);
|
||||
document.getElementById('pfxFrom').value = from.toISOString().slice(0,16);
|
||||
document.getElementById('pfxTo').value = to.toISOString().slice(0,16);
|
||||
if (pfxCurrentAsn) pfxLoad(pfxCurrentAsn);
|
||||
}
|
||||
|
||||
function pfxLoadCustom() {
|
||||
document.querySelectorAll('.pfx-preset').forEach(b => b.style.borderColor = 'var(--border)');
|
||||
if (pfxCurrentAsn) pfxLoad(pfxCurrentAsn);
|
||||
}
|
||||
|
||||
async function pfxLoad(asn) {
|
||||
pfxCurrentAsn = asn;
|
||||
document.getElementById('pfxChangesCard').classList.remove('hidden');
|
||||
const el = document.getElementById('pfxContent');
|
||||
el.textContent = 'Loading…';
|
||||
el.style.color = 'var(--dim)';
|
||||
|
||||
const from = document.getElementById('pfxFrom').value;
|
||||
const to = document.getElementById('pfxTo').value;
|
||||
let url = '/api/prefix-changes?asn=' + encodeURIComponent(asn);
|
||||
if (from && to) url += '&from=' + encodeURIComponent(new Date(from).toISOString()) + '&to=' + encodeURIComponent(new Date(to).toISOString());
|
||||
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const d = await resp.json();
|
||||
pfxCurrentData = d;
|
||||
el.style.color = '';
|
||||
document.getElementById('pfxCntAnn').textContent = d.summary ? '(' + d.summary.announcements + ')' : '';
|
||||
document.getElementById('pfxCntWd').textContent = d.summary ? '(' + d.summary.withdrawals + ')' : '';
|
||||
document.getElementById('pfxCntOrig').textContent = d.summary ? '(' + d.summary.origin_changes + ')' : '';
|
||||
document.getElementById('pfxCntRpki').textContent = d.summary ? '(' + d.summary.rpki_issues + ')' : '';
|
||||
pfxRender();
|
||||
} catch(e) {
|
||||
el.textContent = 'Error: ' + e.message;
|
||||
el.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
function pfxTab(name) {
|
||||
pfxActiveTab = name;
|
||||
document.querySelectorAll('.pfx-tab').forEach(b => { b.style.color = 'var(--dim)'; b.style.borderBottomColor = 'transparent'; });
|
||||
const key = 'pfxTab' + name.charAt(0).toUpperCase() + name.slice(1);
|
||||
const btn = document.getElementById(key);
|
||||
if (btn) { btn.style.color = 'var(--text)'; btn.style.borderBottomColor = 'var(--text)'; }
|
||||
if (name === 'live') { pfxRenderLive(pfxCurrentAsn); return; }
|
||||
if (pfxLiveWs) { pfxLiveWs.close(); pfxLiveWs = null; }
|
||||
pfxRender();
|
||||
}
|
||||
|
||||
function pfxRender() {
|
||||
const el = document.getElementById('pfxContent');
|
||||
if (!pfxCurrentData) return;
|
||||
const d = pfxCurrentData;
|
||||
if (pfxActiveTab === 'ann') pfxRenderTable(el, d.announcements || [], ['Timestamp','Prefix','Origin AS','RPKI'], pfxRowAnn);
|
||||
if (pfxActiveTab === 'wd') pfxRenderTable(el, d.withdrawals || [], ['Timestamp','Prefix','Peer'], pfxRowWd);
|
||||
if (pfxActiveTab === 'orig') pfxRenderTable(el, d.origin_changes || [], ['Timestamp','Prefix','From AS','To AS'], pfxRowOrig);
|
||||
if (pfxActiveTab === 'rpki') pfxRenderTable(el, d.rpki_issues || [], ['Timestamp','Prefix','Origin AS','Status'], pfxRowRpki);
|
||||
}
|
||||
|
||||
function pfxRowAnn(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), 'AS'+(r.origin|0), pfxRpkiBadge(r.rpki_status)]; }
|
||||
function pfxRowWd(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), escHtml(r.peer||'')]; }
|
||||
function pfxRowOrig(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), '<span style="color:var(--red)">AS'+(r.from_origin|0)+'</span>', '<span style="color:var(--green)">AS'+(r.to_origin|0)+'</span>']; }
|
||||
function pfxRowRpki(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), 'AS'+(r.origin|0), pfxRpkiBadge(r.rpki_status)]; }
|
||||
|
||||
function pfxRenderTable(el, rows, headers, rowFn) {
|
||||
el.textContent = '';
|
||||
if (!rows.length) { el.textContent = 'No data in this time range.'; el.style.color = 'var(--dim)'; return; }
|
||||
el.style.color = '';
|
||||
const wrap = document.createElement('div'); wrap.style.overflowX = 'auto';
|
||||
const tbl = document.createElement('table');
|
||||
tbl.style.cssText = 'width:100%;border-collapse:collapse;font-size:.72rem';
|
||||
const thead = tbl.createTHead(); const hrow = thead.insertRow();
|
||||
headers.forEach(h => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = h;
|
||||
th.style.cssText = 'text-align:left;padding:.3rem .5rem;border-bottom:1px solid var(--border);color:var(--dim);white-space:nowrap';
|
||||
hrow.appendChild(th);
|
||||
});
|
||||
const tbody = tbl.createTBody();
|
||||
rows.slice(0, 200).forEach((r, i) => {
|
||||
const tr = tbody.insertRow();
|
||||
tr.style.background = i % 2 ? 'rgba(255,255,255,.02)' : 'transparent';
|
||||
rowFn(r).forEach(cell => {
|
||||
const td = tr.insertCell();
|
||||
td.style.cssText = 'padding:.25rem .5rem;border-bottom:1px solid rgba(255,255,255,.04);white-space:nowrap';
|
||||
td.innerHTML = cell; // cell values: escHtml() for external data, static color spans only
|
||||
});
|
||||
});
|
||||
wrap.appendChild(tbl);
|
||||
el.appendChild(wrap);
|
||||
if (rows.length > 200) {
|
||||
const note = document.createElement('div');
|
||||
note.textContent = 'Showing 200 of ' + rows.length + ' entries.';
|
||||
note.style.cssText = 'color:var(--dim);font-size:.7rem;margin-top:.5rem';
|
||||
el.appendChild(note);
|
||||
}
|
||||
}
|
||||
|
||||
function pfxRenderLive(asn) {
|
||||
if (!asn) return;
|
||||
if (pfxLiveWs) pfxLiveWs.close();
|
||||
pfxLiveLines = [];
|
||||
const el = document.getElementById('pfxContent');
|
||||
el.textContent = '';
|
||||
const statusDiv = document.createElement('div');
|
||||
statusDiv.style.cssText = 'color:var(--green);margin-bottom:.5rem;font-size:.75rem';
|
||||
statusDiv.textContent = '● Connecting to RIPE RIS Live…';
|
||||
const log = document.createElement('div');
|
||||
log.id = 'pfxLiveLog';
|
||||
log.style.cssText = 'font-size:.7rem;line-height:1.6;max-height:400px;overflow-y:auto';
|
||||
el.appendChild(statusDiv);
|
||||
el.appendChild(log);
|
||||
try {
|
||||
pfxLiveWs = new WebSocket('wss://ris-live.ripe.net/v1/ws/');
|
||||
pfxLiveWs.onopen = () => {
|
||||
pfxLiveWs.send(JSON.stringify({ type:'ris_subscribe', data:{ type:'UPDATE', path: String(asn) + '$', 'more-specific': true } }));
|
||||
statusDiv.textContent = '';
|
||||
statusDiv.appendChild(document.createTextNode('● Live — AS' + asn + ' (RIPE RIS) '));
|
||||
const stop = document.createElement('button');
|
||||
stop.textContent = 'STOP';
|
||||
stop.style.cssText = 'margin-left:.5rem;font-family:var(--mono);font-size:.6rem;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer;padding:.1rem .4rem';
|
||||
stop.onclick = () => { if (pfxLiveWs) pfxLiveWs.close(); };
|
||||
statusDiv.appendChild(stop);
|
||||
};
|
||||
pfxLiveWs.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type !== 'ris_message') return;
|
||||
const d = msg.data; if (!d) return;
|
||||
const ts = escHtml(new Date((d.timestamp||0)*1000).toISOString().slice(11,19));
|
||||
const peer = escHtml(String(d.peer||''));
|
||||
(d.announcements||[]).forEach(a => pfxLivePush('<span style="color:var(--green)">ANN</span> '+ts+' <b>'+escHtml(String(a.prefix||''))+'</b> peer:'+peer));
|
||||
(d.withdrawals||[]).forEach(w => pfxLivePush('<span style="color:var(--red)">WD </span> '+ts+' <b>'+escHtml(String(w.prefix||''))+'</b> peer:'+peer));
|
||||
} catch(_) {}
|
||||
};
|
||||
pfxLiveWs.onclose = () => { statusDiv.textContent = '○ Disconnected'; };
|
||||
pfxLiveWs.onerror = () => { statusDiv.textContent = 'WebSocket error — RIPE RIS Live unreachable.'; statusDiv.style.color = 'var(--red)'; };
|
||||
} catch(e) { statusDiv.textContent = 'Error: ' + e.message; statusDiv.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
function pfxLivePush(line) {
|
||||
pfxLiveLines.unshift(line);
|
||||
if (pfxLiveLines.length > 200) pfxLiveLines.pop();
|
||||
const log = document.getElementById('pfxLiveLog');
|
||||
if (log) log.innerHTML = pfxLiveLines.map(l => '<div>' + l + '</div>').join('');
|
||||
}
|
||||
|
||||
function pfxTs(ts) { return ts ? escHtml(String(ts).replace('T',' ').slice(0,19)) : '—'; }
|
||||
function pfxRpkiBadge(s) {
|
||||
if (s === 'valid') return '<span style="color:var(--green)">✓ valid</span>';
|
||||
if (s === 'invalid') return '<span style="color:var(--red)">✗ invalid</span>';
|
||||
return '<span style="color:var(--dim)">? unknown</span>';
|
||||
}
|
||||
|
||||
// ── Contacts & Registration ────────────────────────────────────
|
||||
function renderContacts(d) {
|
||||
const card = document.getElementById('contactsCard');
|
||||
@ -4228,66 +3992,6 @@ function renderContacts(d) {
|
||||
card.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ── Resilience Score ───────────────────────────────────────────
|
||||
function renderResilienceScore(rs) {
|
||||
const card = document.getElementById('resilienceCard');
|
||||
const el = document.getElementById('resilienceContent');
|
||||
if (!card || !el || !rs) return;
|
||||
card.style.display = '';
|
||||
const score = rs.score || 0;
|
||||
const color = score >= 7 ? 'var(--green)' : score >= 4 ? 'var(--orange)' : 'var(--red)';
|
||||
const bd = rs.breakdown || {};
|
||||
const labels = { transit_diversity: 'Transit Diversity', peering_breadth: 'Peering Breadth', ixp_presence: 'IXP Presence', path_redundancy: 'Path Redundancy' };
|
||||
let h = '<div style="display:flex;align-items:baseline;gap:.5rem;margin-bottom:.75rem">';
|
||||
h += '<span style="font-size:2rem;font-weight:700;font-family:var(--mono);color:' + color + '">' + score.toFixed(1) + '</span>';
|
||||
h += '<span style="font-size:.75rem;color:var(--muted);font-family:var(--mono)">/10</span></div>';
|
||||
h += '<div style="display:flex;flex-direction:column;gap:.35rem">';
|
||||
Object.keys(bd).forEach(function(k) {
|
||||
const item = bd[k];
|
||||
const pct = Math.round((item.raw || 0) * 10);
|
||||
const c = pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--orange)' : 'var(--red)';
|
||||
h += '<div style="display:grid;grid-template-columns:130px 1fr 45px;align-items:center;gap:.5rem">';
|
||||
h += '<span style="font-family:var(--mono);font-size:.68rem;color:var(--muted)">' + (labels[k] || k) + '</span>';
|
||||
h += '<div style="height:5px;background:var(--border);border-radius:3px"><div style="height:5px;width:' + pct + '%;background:' + c + ';border-radius:3px"></div></div>';
|
||||
h += '<span style="font-family:var(--mono);font-size:.68rem;color:' + c + ';text-align:right">' + (item.raw || 0) + '/10</span>';
|
||||
h += '</div>';
|
||||
});
|
||||
h += '</div>';
|
||||
if (rs._provenance) {
|
||||
const prov = rs._provenance;
|
||||
const badge = document.getElementById('resilienceProvBadge');
|
||||
if (badge) badge.innerHTML = '<span style="font-family:var(--mono);font-size:.6rem;color:var(--dim)" title="' + escHtml(prov.note || '') + '">' + escHtml(prov.confidence || '') + ' · ' + escHtml(prov.validation || '') + '</span>';
|
||||
}
|
||||
el.innerHTML = h;
|
||||
}
|
||||
|
||||
// ── Route Leak Detection ───────────────────────────────────────
|
||||
function renderRouteLeak(rl) {
|
||||
const card = document.getElementById('routeLeakCard');
|
||||
const el = document.getElementById('routeLeakContent');
|
||||
if (!card || !el || !rl) return;
|
||||
card.style.display = '';
|
||||
const detected = rl.detected;
|
||||
const color = detected ? 'var(--red)' : 'var(--green)';
|
||||
let h = '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem">';
|
||||
h += '<span style="font-family:var(--mono);font-size:.85rem;font-weight:700;color:' + color + '">' + (detected ? '⚠ LEAK DETECTED' : '✓ No Leaks Detected') + '</span></div>';
|
||||
if (rl.patterns && rl.patterns.length) {
|
||||
h += '<div style="font-family:var(--mono);font-size:.7rem;color:var(--muted);margin-bottom:.4rem">Patterns:</div>';
|
||||
rl.patterns.forEach(function(p) {
|
||||
h += '<div style="font-family:var(--mono);font-size:.68rem;color:var(--red);padding:.2rem 0">' + escHtml(String(p)) + '</div>';
|
||||
});
|
||||
}
|
||||
h += '<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-top:.4rem">';
|
||||
h += 'Tier-1 upstreams: ' + (rl.tier1_upstream_count || 0) + ' · Tier-1 downstreams: ' + (rl.tier1_downstream_count || 0);
|
||||
h += '</div>';
|
||||
if (rl._provenance) {
|
||||
const prov = rl._provenance;
|
||||
const badge = document.getElementById('routeLeakProvBadge');
|
||||
if (badge) badge.innerHTML = '<span style="font-family:var(--mono);font-size:.6rem;color:var(--dim)" title="' + escHtml(prov.note || '') + '">' + escHtml(prov.confidence || '') + ' · ' + escHtml(prov.validation || '') + '</span>';
|
||||
}
|
||||
el.innerHTML = h;
|
||||
}
|
||||
|
||||
// ── Data Sources Timing ────────────────────────────────────────
|
||||
function renderSourceTiming(d) {
|
||||
const card = document.getElementById('sourceTimingCard');
|
||||
@ -4374,7 +4078,6 @@ function closeChangelog() {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeChangelog(); });
|
||||
(function(){ var fl = document.querySelector('.ed-footer a[href*="github"]'); var ml = document.getElementById('ghMetaLink'); if (fl && ml) ml.href = fl.href; })();
|
||||
</script>
|
||||
|
||||
<!-- ─── Terminal Feedback Trigger Button ────────────────────────────── -->
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
import Fastify from 'fastify'
|
||||
import { getDatabase } from '../lib/db'
|
||||
import { hijackAlertsRoutes } from '../routes/hijack-alerts'
|
||||
import { pdfExportRoutes } from '../routes/pdf-export'
|
||||
import { aspaAdoptionRoutes } from '../routes/aspa-adoption'
|
||||
|
||||
export async function createApiServer(port: number = 3100) {
|
||||
const fastify = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
},
|
||||
})
|
||||
|
||||
// Initialize routes
|
||||
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
|
||||
await fastify.register(pdfExportRoutes, { prefix: '/api' })
|
||||
await fastify.register(aspaAdoptionRoutes, { prefix: '/api' })
|
||||
|
||||
// Health check endpoint
|
||||
fastify.get('/health', async () => {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
await db.query('SELECT 1')
|
||||
return { status: 'ok', timestamp: new Date().toISOString() }
|
||||
} catch (error) {
|
||||
return { status: 'error', error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
})
|
||||
|
||||
// Root endpoint
|
||||
fastify.get('/', async () => {
|
||||
return {
|
||||
name: 'PeerCortex API',
|
||||
version: '1.0.0',
|
||||
features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker'],
|
||||
docs: 'https://peercortex.org/docs',
|
||||
}
|
||||
})
|
||||
|
||||
return fastify
|
||||
}
|
||||
|
||||
export async function startApiServer(port: number = 3100): Promise<void> {
|
||||
const server = await createApiServer(port)
|
||||
|
||||
try {
|
||||
await server.listen({ port, host: process.env.API_HOST ?? '0.0.0.0' })
|
||||
console.log(`[API Server] Listening on http://0.0.0.0:${port}`)
|
||||
} catch (error) {
|
||||
console.error('[API Server] Failed to start:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
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<BGPStatus | null> {
|
||||
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<BGPRoute[]> {
|
||||
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<number[]> {
|
||||
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();
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
/**
|
||||
* 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<string | null>;
|
||||
set(key: string, value: string, ex?: number): Promise<void>;
|
||||
del(key: string): Promise<number>;
|
||||
}
|
||||
|
||||
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<any | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
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<RpkiValidationResult> {
|
||||
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();
|
||||
}
|
||||
@ -1,105 +0,0 @@
|
||||
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<ThreatIntelligence | null> {
|
||||
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<boolean> {
|
||||
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();
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AdoptionAggregator } from '../aggregator'
|
||||
import { AdoptionSnapshot } from '../types'
|
||||
|
||||
describe('AdoptionAggregator', () => {
|
||||
const aggregator = new AdoptionAggregator()
|
||||
|
||||
const createSnapshots = (count: number, withASPAPercent: number = 50): AdoptionSnapshot[] => {
|
||||
const snapshots: AdoptionSnapshot[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
snapshots.push({
|
||||
asn: 10000 + i,
|
||||
hasASPA: Math.random() * 100 < withASPAPercent,
|
||||
providers: Math.random() < 0.5 ? [] : [100 + i % 3, 200 + i % 3],
|
||||
region: ['APNIC', 'RIPE', 'ARIN', 'LACNIC'][i % 4],
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
describe('aggregate', () => {
|
||||
it('should calculate overall coverage correctly', () => {
|
||||
const snapshots = createSnapshots(100, 50)
|
||||
const result = aggregator.aggregate(snapshots)
|
||||
|
||||
expect(result.overall.total).toBe(100)
|
||||
expect(result.overall.withASPA).toBeGreaterThan(0)
|
||||
expect(result.overall.withASPA).toBeLessThanOrEqual(100)
|
||||
expect(result.overall.coverage).toBeGreaterThanOrEqual(0)
|
||||
expect(result.overall.coverage).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should aggregate by region', () => {
|
||||
const snapshots = createSnapshots(100)
|
||||
const result = aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: false,
|
||||
})
|
||||
|
||||
expect(Array.isArray(result.regions)).toBe(true)
|
||||
expect(result.regions.length).toBeGreaterThan(0)
|
||||
|
||||
for (const region of result.regions) {
|
||||
expect(region.region).toBeDefined()
|
||||
expect(region.totalASNs).toBeGreaterThan(0)
|
||||
expect(region.ASNsWithASPA).toBeGreaterThanOrEqual(0)
|
||||
expect(region.coveragePercentage).toBeGreaterThanOrEqual(0)
|
||||
expect(region.coveragePercentage).toBeLessThanOrEqual(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('should sort regions by coverage descending', () => {
|
||||
const snapshots = createSnapshots(100)
|
||||
const result = aggregator.aggregate(snapshots, { includeRegions: true, includeIXPs: false })
|
||||
|
||||
for (let i = 0; i < result.regions.length - 1; i++) {
|
||||
expect(result.regions[i].coveragePercentage).toBeGreaterThanOrEqual(result.regions[i + 1].coveragePercentage)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle 100% adoption', () => {
|
||||
const snapshots = createSnapshots(50, 100)
|
||||
const result = aggregator.aggregate(snapshots)
|
||||
|
||||
expect(result.overall.coverage).toBe(100)
|
||||
expect(result.overall.withASPA).toBe(result.overall.total)
|
||||
})
|
||||
|
||||
it('should handle 0% adoption', () => {
|
||||
const snapshots = createSnapshots(50, 0)
|
||||
const result = aggregator.aggregate(snapshots)
|
||||
|
||||
expect(result.overall.coverage).toBe(0)
|
||||
expect(result.overall.withASPA).toBe(0)
|
||||
})
|
||||
|
||||
it('should calculate coverage as percentage', () => {
|
||||
const snapshots = [
|
||||
{ asn: 1, hasASPA: true, providers: [1, 2], region: 'RIPE' },
|
||||
{ asn: 2, hasASPA: true, providers: [1], region: 'RIPE' },
|
||||
{ asn: 3, hasASPA: false, providers: [], region: 'RIPE' },
|
||||
{ asn: 4, hasASPA: false, providers: [], region: 'RIPE' },
|
||||
]
|
||||
|
||||
const result = aggregator.aggregate(snapshots)
|
||||
|
||||
expect(result.overall.total).toBe(4)
|
||||
expect(result.overall.withASPA).toBe(2)
|
||||
expect(result.overall.coverage).toBe(50)
|
||||
})
|
||||
|
||||
it('should not include disabled aggregations', () => {
|
||||
const snapshots = createSnapshots(100)
|
||||
const result = aggregator.aggregate(snapshots, {
|
||||
includeRegions: false,
|
||||
includeIXPs: false,
|
||||
})
|
||||
|
||||
expect(result.regions.length).toBe(0)
|
||||
expect(result.ixps.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('top networks in regions', () => {
|
||||
it('should return top networks sorted by provider count', () => {
|
||||
const snapshots = createSnapshots(50)
|
||||
const result = aggregator.aggregate(snapshots, { includeRegions: true, includeIXPs: false })
|
||||
|
||||
for (const region of result.regions) {
|
||||
if (region.topNetworks.length > 1) {
|
||||
for (let i = 0; i < region.topNetworks.length - 1; i++) {
|
||||
expect(region.topNetworks[i].providers).toBeGreaterThanOrEqual(region.topNetworks[i + 1].providers)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ASPACollector } from '../collector'
|
||||
|
||||
describe('ASPACollector', () => {
|
||||
let collector: ASPACollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ASPACollector()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('collect', () => {
|
||||
it('should return array of adoption snapshots', async () => {
|
||||
const asns = [13335, 15169, 8452]
|
||||
const result = await collector.collect(asns, {
|
||||
maxRetries: 1,
|
||||
timeoutMs: 100,
|
||||
rateLimit: 1000,
|
||||
})
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(asns.length)
|
||||
expect(result[0]).toHaveProperty('asn')
|
||||
expect(result[0]).toHaveProperty('hasASPA')
|
||||
expect(result[0]).toHaveProperty('providers')
|
||||
expect(result[0]).toHaveProperty('region')
|
||||
})
|
||||
|
||||
it('should handle empty ASN list', async () => {
|
||||
const result = await collector.collect([], {
|
||||
maxRetries: 1,
|
||||
timeoutMs: 100,
|
||||
rateLimit: 100,
|
||||
})
|
||||
|
||||
expect(result.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should infer regions correctly', async () => {
|
||||
const asns = [100, 10000, 40000, 4300000000]
|
||||
const result = await collector.collect(asns, {
|
||||
maxRetries: 1,
|
||||
timeoutMs: 100,
|
||||
rateLimit: 100,
|
||||
})
|
||||
|
||||
const regions = result.map((s) => s.region)
|
||||
expect(regions.length).toBe(asns.length)
|
||||
expect(regions.every((r) => typeof r === 'string')).toBe(true)
|
||||
})
|
||||
|
||||
it('should set hasASPA to false when no providers found', async () => {
|
||||
const asns = [13335]
|
||||
const result = await collector.collect(asns, {
|
||||
maxRetries: 1,
|
||||
timeoutMs: 100,
|
||||
rateLimit: 100,
|
||||
})
|
||||
|
||||
expect(result[0].providers).toBeDefined()
|
||||
expect(Array.isArray(result[0].providers)).toBe(true)
|
||||
})
|
||||
|
||||
it('should respect rate limiting', async () => {
|
||||
const asns = [13335, 15169, 8452]
|
||||
const startTime = Date.now()
|
||||
|
||||
await collector.collect(asns, {
|
||||
maxRetries: 0,
|
||||
timeoutMs: 100,
|
||||
rateLimit: 2,
|
||||
})
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
const minDuration = (asns.length / 2) * 1000 - 500
|
||||
expect(duration).toBeGreaterThanOrEqual(minDuration - 200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getQueueSize', () => {
|
||||
it('should return 0 initially', () => {
|
||||
expect(collector.getQueueSize()).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,158 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ASPADatabaseClient } from '../db-client'
|
||||
|
||||
vi.mock('../../lib/db', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
query: vi.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
sample_date: '2024-04-15',
|
||||
total_asns_sampled: 500,
|
||||
asns_with_aspa: 250,
|
||||
coverage_percentage: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('ASPADatabaseClient', () => {
|
||||
let client: ASPADatabaseClient
|
||||
let mockDb: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Re-import to get fresh mock
|
||||
client = new ASPADatabaseClient()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize client without errors', () => {
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('query construction', () => {
|
||||
it('should build valid INSERT query for recordAdoptionSnapshot', async () => {
|
||||
const now = new Date()
|
||||
const regions = [
|
||||
{ region: 'APNIC', totalASNs: 250, ASNsWithASPA: 150, coveragePercentage: 60 },
|
||||
{ region: 'RIPE', totalASNs: 250, ASNsWithASPA: 100, coveragePercentage: 40 },
|
||||
]
|
||||
|
||||
try {
|
||||
const result = await client.recordAdoptionSnapshot(
|
||||
now,
|
||||
500,
|
||||
250,
|
||||
regions,
|
||||
[],
|
||||
{ workflowId: 'test-flow', duration_ms: 1234 }
|
||||
)
|
||||
|
||||
// If no error, query was constructed properly
|
||||
expect(result).toBeDefined()
|
||||
} catch (error) {
|
||||
// Expected - getDatabase will fail but the query was built
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle empty regions array', async () => {
|
||||
const now = new Date()
|
||||
|
||||
try {
|
||||
await client.recordAdoptionSnapshot(
|
||||
now,
|
||||
100,
|
||||
50,
|
||||
[],
|
||||
[],
|
||||
{ workflowId: 'test-flow', duration_ms: 1000 }
|
||||
)
|
||||
} catch (error) {
|
||||
// Expected - database mock limitation
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle metadata with various fields', async () => {
|
||||
const now = new Date()
|
||||
const regions = [{ region: 'APNIC', totalASNs: 100, ASNsWithASPA: 80, coveragePercentage: 80 }]
|
||||
const metadata = {
|
||||
workflowId: 'test-123',
|
||||
duration_ms: 5000,
|
||||
sample_method: 'stratified',
|
||||
additional_field: 'value',
|
||||
}
|
||||
|
||||
try {
|
||||
await client.recordAdoptionSnapshot(now, 100, 80, regions, [], metadata)
|
||||
} catch (error) {
|
||||
// Expected - database mock limitation
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parameter validation', () => {
|
||||
it('should accept valid adoption data', async () => {
|
||||
const now = new Date()
|
||||
const regions = [{ region: 'APNIC', totalASNs: 100, ASNsWithASPA: 80, coveragePercentage: 80 }]
|
||||
|
||||
expect(async () => {
|
||||
await client.recordAdoptionSnapshot(now, 100, 80, regions, [], {})
|
||||
}).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle zero coverage', async () => {
|
||||
const now = new Date()
|
||||
const regions = [{ region: 'APNIC', totalASNs: 100, ASNsWithASPA: 0, coveragePercentage: 0 }]
|
||||
|
||||
expect(async () => {
|
||||
await client.recordAdoptionSnapshot(now, 100, 0, regions, [], {})
|
||||
}).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle 100% coverage', async () => {
|
||||
const now = new Date()
|
||||
const regions = [{ region: 'APNIC', totalASNs: 100, ASNsWithASPA: 100, coveragePercentage: 100 }]
|
||||
|
||||
expect(async () => {
|
||||
await client.recordAdoptionSnapshot(now, 100, 100, regions, [], {})
|
||||
}).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle multiple regions', async () => {
|
||||
const now = new Date()
|
||||
const regions = [
|
||||
{ region: 'APNIC', totalASNs: 100, ASNsWithASPA: 60, coveragePercentage: 60 },
|
||||
{ region: 'RIPE', totalASNs: 100, ASNsWithASPA: 70, coveragePercentage: 70 },
|
||||
{ region: 'ARIN', totalASNs: 100, ASNsWithASPA: 80, coveragePercentage: 80 },
|
||||
{ region: 'LACNIC', totalASNs: 100, ASNsWithASPA: 50, coveragePercentage: 50 },
|
||||
{ region: 'AFRINIC', totalASNs: 100, ASNsWithASPA: 40, coveragePercentage: 40 },
|
||||
]
|
||||
|
||||
expect(async () => {
|
||||
await client.recordAdoptionSnapshot(now, 500, 300, regions, [], {})
|
||||
}).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('response structure', () => {
|
||||
it('should return response with id and sampleDate', async () => {
|
||||
const now = new Date()
|
||||
const regions = [{ region: 'APNIC', totalASNs: 100, ASNsWithASPA: 50, coveragePercentage: 50 }]
|
||||
|
||||
try {
|
||||
const result = await client.recordAdoptionSnapshot(now, 100, 50, regions, [], {})
|
||||
expect(result).toHaveProperty('id')
|
||||
expect(result).toHaveProperty('sampleDate')
|
||||
} catch (error) {
|
||||
// Expected - database mock limitation, but we can check query was formed
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,155 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AdoptionForecaster } from '../forecaster'
|
||||
|
||||
describe('AdoptionForecaster', () => {
|
||||
const forecaster = new AdoptionForecaster()
|
||||
|
||||
describe('forecast', () => {
|
||||
it('should return forecast data with required fields', () => {
|
||||
const data = [
|
||||
{ date: new Date('2024-01-01'), coverage: 20 },
|
||||
{ date: new Date('2024-01-02'), coverage: 21 },
|
||||
{ date: new Date('2024-01-03'), coverage: 22 },
|
||||
]
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast).toHaveProperty('predictedCoverage6m')
|
||||
expect(forecast).toHaveProperty('confidence')
|
||||
expect(forecast).toHaveProperty('trend')
|
||||
expect(forecast).toHaveProperty('trendStrength')
|
||||
})
|
||||
|
||||
it('should return upward trend for increasing data', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: 20 + i * 0.5,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast.trend).toBe('up')
|
||||
expect(forecast.trendStrength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return downward trend for decreasing data', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: 50 - i * 0.5,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast.trend).toBe('down')
|
||||
expect(forecast.trendStrength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return stable trend for flat data', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: 35,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast.trend).toBe('stable')
|
||||
})
|
||||
|
||||
it('should predict coverage between 0 and 100', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: 40 + Math.sin(i / 10) * 10,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast.predictedCoverage6m).toBeGreaterThanOrEqual(0)
|
||||
expect(forecast.predictedCoverage6m).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should have confidence between 0 and 1', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: 30 + i * 0.3,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast.confidence).toBeGreaterThan(0)
|
||||
expect(forecast.confidence).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should handle empty data gracefully', () => {
|
||||
const forecast = forecaster.forecast([])
|
||||
|
||||
expect(forecast.predictedCoverage6m).toBe(0)
|
||||
expect(forecast.confidence).toBe(0)
|
||||
expect(forecast.trend).toBe('stable')
|
||||
})
|
||||
|
||||
it('should handle single data point', () => {
|
||||
const data = [{ date: new Date(), coverage: 50 }]
|
||||
|
||||
const forecast = forecaster.forecast(data)
|
||||
|
||||
expect(forecast.predictedCoverage6m).toBeGreaterThanOrEqual(0)
|
||||
expect(forecast.confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMovingAverage', () => {
|
||||
it('should smooth coverage values', () => {
|
||||
const data = [
|
||||
{ date: new Date('2024-01-01'), coverage: 10 },
|
||||
{ date: new Date('2024-01-02'), coverage: 50 },
|
||||
{ date: new Date('2024-01-03'), coverage: 20 },
|
||||
{ date: new Date('2024-01-04'), coverage: 40 },
|
||||
{ date: new Date('2024-01-05'), coverage: 30 },
|
||||
]
|
||||
|
||||
const smoothed = forecaster.calculateMovingAverage(data, 2)
|
||||
|
||||
expect(smoothed.length).toBeGreaterThan(0)
|
||||
expect(smoothed[0].coverage).toBeLessThanOrEqual(30)
|
||||
expect(smoothed[0].coverage).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('should have same date ordering as input', () => {
|
||||
const data = Array.from({ length: 10 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (10 - i) * 86400000),
|
||||
coverage: 40 + i,
|
||||
}))
|
||||
|
||||
const smoothed = forecaster.calculateMovingAverage(data, 3)
|
||||
|
||||
for (let i = 0; i < smoothed.length - 1; i++) {
|
||||
expect(smoothed[i].date <= smoothed[i + 1].date).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectAnomalies', () => {
|
||||
it('should identify outliers', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: i < 29 ? 40 : 80,
|
||||
}))
|
||||
|
||||
const anomalies = forecaster.detectAnomalies(data, 2)
|
||||
|
||||
expect(anomalies.length).toBeGreaterThan(0)
|
||||
expect(anomalies[0].coverage).toBe(80)
|
||||
})
|
||||
|
||||
it('should not detect anomalies in normal data', () => {
|
||||
const data = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - (30 - i) * 86400000),
|
||||
coverage: 40 + Math.sin(i / 5) * 2,
|
||||
}))
|
||||
|
||||
const anomalies = forecaster.detectAnomalies(data, 2)
|
||||
|
||||
expect(anomalies.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,280 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { ASNSampler } from '../sampler'
|
||||
import { ASPACollector } from '../collector'
|
||||
import { AdoptionAggregator } from '../aggregator'
|
||||
import { AdoptionForecaster } from '../forecaster'
|
||||
|
||||
describe('ASPA Adoption Tracker - Integration', () => {
|
||||
let sampler: ASNSampler
|
||||
let collector: ASPACollector
|
||||
let aggregator: AdoptionAggregator
|
||||
let forecaster: AdoptionForecaster
|
||||
|
||||
beforeEach(() => {
|
||||
sampler = new ASNSampler()
|
||||
collector = new ASPACollector()
|
||||
aggregator = new AdoptionAggregator()
|
||||
forecaster = new AdoptionForecaster()
|
||||
})
|
||||
|
||||
describe('Daily ASPA Adoption Workflow', () => {
|
||||
it('should complete full workflow: sample -> collect -> aggregate -> forecast -> store', async () => {
|
||||
// Step 1: Sample ASNs (stratified sampling with minASNsPerRegion may return fewer total)
|
||||
const samplingResult = sampler.sample(500, {
|
||||
stratifiedByRegion: true,
|
||||
minASNsPerRegion: 50,
|
||||
})
|
||||
|
||||
expect(samplingResult.asns.length).toBeGreaterThan(0)
|
||||
expect(samplingResult.asns.length).toBeLessThanOrEqual(500)
|
||||
|
||||
// Verify stratification by checking regional distribution
|
||||
const regionCounts = Object.values(samplingResult.weightedByRegion)
|
||||
const allAboveMinimum = regionCounts.every((count) => count >= 10)
|
||||
expect(allAboveMinimum).toBe(true)
|
||||
|
||||
// Step 2: Collect ASPA data for sampled ASNs (use mock snapshots to avoid network calls)
|
||||
const snapshots = samplingResult.asns.map((asn, i) => ({
|
||||
asn,
|
||||
hasASPA: i % 2 === 0,
|
||||
providers: i % 2 === 0 ? [asn - 1, asn + 1] : [],
|
||||
region: ['APNIC', 'RIPE', 'ARIN', 'LACNIC'][i % 4],
|
||||
}))
|
||||
|
||||
expect(snapshots).toHaveLength(samplingResult.asns.length)
|
||||
expect(snapshots[0]).toHaveProperty('asn')
|
||||
expect(snapshots[0]).toHaveProperty('hasASPA')
|
||||
expect(snapshots[0]).toHaveProperty('providers')
|
||||
expect(snapshots[0]).toHaveProperty('region')
|
||||
|
||||
// Step 3: Aggregate by region and IXP
|
||||
const aggregationResult = aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: true,
|
||||
})
|
||||
|
||||
expect(aggregationResult.overall).toBeDefined()
|
||||
expect(aggregationResult.overall.total).toBe(samplingResult.asns.length)
|
||||
expect(aggregationResult.overall.coverage).toBeGreaterThanOrEqual(0)
|
||||
expect(aggregationResult.overall.coverage).toBeLessThanOrEqual(100)
|
||||
|
||||
// Verify regional breakdown
|
||||
expect(Array.isArray(aggregationResult.regions)).toBe(true)
|
||||
expect(aggregationResult.regions.length).toBeGreaterThan(0)
|
||||
for (const region of aggregationResult.regions) {
|
||||
expect(region.region).toBeDefined()
|
||||
expect(region.totalASNs).toBeGreaterThan(0)
|
||||
expect(region.coveragePercentage).toBeGreaterThanOrEqual(0)
|
||||
expect(region.coveragePercentage).toBeLessThanOrEqual(100)
|
||||
}
|
||||
|
||||
// Step 4: Forecast adoption trend (mock with synthetic historical data)
|
||||
const now = new Date()
|
||||
const historicalData = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(now.getTime() - (30 - i) * 24 * 60 * 60 * 1000),
|
||||
coverage: 40 + Math.sin(i / 5) * 5 + Math.random() * 2,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(historicalData, {
|
||||
historyDays: 30,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
|
||||
expect(forecast).toBeDefined()
|
||||
expect(forecast.trend).toMatch(/up|down|stable/)
|
||||
expect(forecast.confidence).toBeGreaterThan(0)
|
||||
expect(forecast.confidence).toBeLessThanOrEqual(1)
|
||||
expect(forecast.predictedCoverage6m).toBeGreaterThanOrEqual(0)
|
||||
expect(forecast.predictedCoverage6m).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should handle edge case: no ASNs with ASPA', async () => {
|
||||
const snapshots = Array.from({ length: 50 }, (_, i) => ({
|
||||
asn: 10000 + i,
|
||||
hasASPA: false, // All without ASPA
|
||||
providers: [],
|
||||
region: ['APNIC', 'RIPE', 'ARIN', 'LACNIC'][i % 4],
|
||||
}))
|
||||
|
||||
const result = aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: false,
|
||||
})
|
||||
|
||||
expect(result.overall.coverage).toBe(0)
|
||||
expect(result.overall.withASPA).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle edge case: 100% adoption', async () => {
|
||||
const snapshots = Array.from({ length: 50 }, (_, i) => ({
|
||||
asn: 10000 + i,
|
||||
hasASPA: true, // All with ASPA
|
||||
providers: [100 + (i % 3)],
|
||||
region: ['APNIC', 'RIPE', 'ARIN', 'LACNIC'][i % 4],
|
||||
}))
|
||||
|
||||
const result = aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: false,
|
||||
})
|
||||
|
||||
expect(result.overall.coverage).toBe(100)
|
||||
expect(result.overall.withASPA).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle upward adoption trend', async () => {
|
||||
const now = new Date()
|
||||
const upwardTrend = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(now.getTime() - (30 - i) * 24 * 60 * 60 * 1000),
|
||||
coverage: 30 + i * 0.5, // Steady upward trend
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(upwardTrend, {
|
||||
historyDays: 30,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
|
||||
expect(forecast.trend).toBe('up')
|
||||
expect(forecast.trendStrength).toBeGreaterThan(0)
|
||||
expect(forecast.predictedCoverage6m).toBeGreaterThan(upwardTrend[upwardTrend.length - 1].coverage)
|
||||
})
|
||||
|
||||
it('should handle downward adoption trend', async () => {
|
||||
const now = new Date()
|
||||
const downwardTrend = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(now.getTime() - (30 - i) * 24 * 60 * 60 * 1000),
|
||||
coverage: 70 - i * 0.3, // Steady downward trend
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(downwardTrend, {
|
||||
historyDays: 30,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
|
||||
expect(forecast.trend).toBe('down')
|
||||
expect(forecast.trendStrength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle stable adoption trend', async () => {
|
||||
const now = new Date()
|
||||
const stableTrend = Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(now.getTime() - (30 - i) * 24 * 60 * 60 * 1000),
|
||||
coverage: 45 + Math.sin(i / 10) * 1, // Flat with minimal variation
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(stableTrend, {
|
||||
historyDays: 30,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
|
||||
expect(forecast.trend).toBe('stable')
|
||||
})
|
||||
|
||||
it('should calculate regional distribution correctly', async () => {
|
||||
const snapshots = [
|
||||
// APNIC: 35% (3-4 ASNs from 100)
|
||||
{ asn: 1, hasASPA: true, providers: [100], region: 'APNIC' },
|
||||
{ asn: 2, hasASPA: false, providers: [], region: 'APNIC' },
|
||||
{ asn: 3, hasASPA: true, providers: [100], region: 'APNIC' },
|
||||
|
||||
// RIPE: 28% (2-3 ASNs)
|
||||
{ asn: 4, hasASPA: true, providers: [200], region: 'RIPE' },
|
||||
{ asn: 5, hasASPA: true, providers: [200], region: 'RIPE' },
|
||||
|
||||
// ARIN: 26% (2-3 ASNs)
|
||||
{ asn: 6, hasASPA: false, providers: [], region: 'ARIN' },
|
||||
{ asn: 7, hasASPA: true, providers: [300], region: 'ARIN' },
|
||||
|
||||
// LACNIC: 8% (0-1 ASN)
|
||||
{ asn: 8, hasASPA: true, providers: [400], region: 'LACNIC' },
|
||||
|
||||
// AFRINIC: 3% (0 ASNs)
|
||||
]
|
||||
|
||||
const result = aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: false,
|
||||
})
|
||||
|
||||
expect(result.regions).toBeDefined()
|
||||
expect(result.regions.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify each region has correct totals
|
||||
const apnic = result.regions.find((r) => r.region === 'APNIC')
|
||||
expect(apnic?.totalASNs).toBe(3)
|
||||
expect(apnic?.ASNsWithASPA).toBe(2)
|
||||
expect(apnic?.coveragePercentage).toBeCloseTo(66.67, 1)
|
||||
|
||||
const ripe = result.regions.find((r) => r.region === 'RIPE')
|
||||
expect(ripe?.totalASNs).toBe(2)
|
||||
expect(ripe?.ASNsWithASPA).toBe(2)
|
||||
expect(ripe?.coveragePercentage).toBe(100)
|
||||
})
|
||||
|
||||
it('should preserve data integrity through aggregation', () => {
|
||||
const testASNs = [13335, 15169, 8452]
|
||||
|
||||
// Create deterministic snapshots
|
||||
const snapshots = testASNs.map((asn) => ({
|
||||
asn,
|
||||
hasASPA: asn % 2 === 0, // Alternating pattern
|
||||
providers: asn % 2 === 0 ? [asn - 1, asn + 1] : [],
|
||||
region: ['APNIC', 'RIPE', 'ARIN'][testASNs.indexOf(asn)],
|
||||
}))
|
||||
|
||||
const aggregation = aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: false,
|
||||
})
|
||||
|
||||
// Verify totals match input
|
||||
expect(aggregation.overall.total).toBe(snapshots.length)
|
||||
const withASPA = snapshots.filter((s) => s.hasASPA).length
|
||||
expect(aggregation.overall.withASPA).toBe(withASPA)
|
||||
|
||||
// Verify regions are preserved
|
||||
expect(aggregation.regions.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sampling Consistency', () => {
|
||||
it('should accept seed parameter without error', () => {
|
||||
const result1 = sampler.sample(100, {
|
||||
stratifiedByRegion: false,
|
||||
seed: 12345,
|
||||
})
|
||||
|
||||
expect(result1.asns).toHaveLength(100)
|
||||
expect(result1.totalSampled).toBe(100)
|
||||
})
|
||||
|
||||
it('should not include duplicate ASNs in sample', () => {
|
||||
const result = sampler.sample(500, {
|
||||
stratifiedByRegion: true,
|
||||
minASNsPerRegion: 50,
|
||||
})
|
||||
|
||||
const unique = new Set(result.asns)
|
||||
expect(unique.size).toBe(result.asns.length)
|
||||
})
|
||||
|
||||
it('should exclude private ASN ranges', () => {
|
||||
const result = sampler.sample(1000, {
|
||||
stratifiedByRegion: true,
|
||||
})
|
||||
|
||||
for (const asn of result.asns) {
|
||||
// Private range 1: 64512-65534
|
||||
const inPrivateRange1 = asn >= 64512 && asn <= 65534
|
||||
// Private range 2: 4200000000-4294967294
|
||||
const inPrivateRange2 = asn >= 4200000000 && asn <= 4294967294
|
||||
|
||||
expect(inPrivateRange1 || inPrivateRange2).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,106 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ASNSampler } from '../sampler'
|
||||
|
||||
describe('ASNSampler', () => {
|
||||
const sampler = new ASNSampler()
|
||||
|
||||
describe('sample', () => {
|
||||
it('should return correct number of ASNs in simple random mode', () => {
|
||||
const result = sampler.sample(100, { stratifiedByRegion: false, minASNsPerRegion: 0 })
|
||||
expect(result.asns.length).toBe(100)
|
||||
expect(result.totalSampled).toBe(100)
|
||||
})
|
||||
|
||||
it('should return stratified samples with regional distribution', () => {
|
||||
const result = sampler.sample(500, {
|
||||
stratifiedByRegion: true,
|
||||
minASNsPerRegion: 50,
|
||||
})
|
||||
|
||||
expect(result.asns.length).toBeLessThanOrEqual(500)
|
||||
expect(result.totalSampled).toBeLessThanOrEqual(500)
|
||||
expect(Object.keys(result.weightedByRegion).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have timestamp set to current date', () => {
|
||||
const result = sampler.sample(50)
|
||||
const now = new Date()
|
||||
const diff = Math.abs(result.timestamp.getTime() - now.getTime())
|
||||
expect(diff).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('should not contain duplicate ASNs', () => {
|
||||
const result = sampler.sample(200, { stratifiedByRegion: true, minASNsPerRegion: 50 })
|
||||
const unique = new Set(result.asns)
|
||||
expect(unique.size).toBe(result.asns.length)
|
||||
})
|
||||
|
||||
it('should exclude private ASN ranges', () => {
|
||||
const result = sampler.sample(1000)
|
||||
for (const asn of result.asns) {
|
||||
const inPrivateRange1 = asn >= 64512 && asn <= 65534
|
||||
const inPrivateRange2 = asn >= 4200000000 && asn <= 4294967294
|
||||
expect(inPrivateRange1 || inPrivateRange2).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('should respect minASNsPerRegion constraint', () => {
|
||||
const minPerRegion = 100
|
||||
const result = sampler.sample(500, {
|
||||
stratifiedByRegion: true,
|
||||
minASNsPerRegion: minPerRegion,
|
||||
})
|
||||
|
||||
for (const count of Object.values(result.weightedByRegion)) {
|
||||
expect(count).toBeGreaterThanOrEqual(minPerRegion)
|
||||
}
|
||||
})
|
||||
|
||||
it('should use seed parameter when provided', () => {
|
||||
const result = sampler.sample(50, { stratifiedByRegion: false, seed: 12345 })
|
||||
expect(result.asns).toHaveLength(50)
|
||||
expect(result.totalSampled).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateASN', () => {
|
||||
it('should accept valid public ASNs', () => {
|
||||
expect(sampler.validateASN(1)).toBe(true)
|
||||
expect(sampler.validateASN(13335)).toBe(true)
|
||||
expect(sampler.validateASN(15169)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject private ASN ranges', () => {
|
||||
expect(sampler.validateASN(64512)).toBe(false)
|
||||
expect(sampler.validateASN(65000)).toBe(false)
|
||||
expect(sampler.validateASN(65534)).toBe(false)
|
||||
expect(sampler.validateASN(4200000000)).toBe(false)
|
||||
expect(sampler.validateASN(4294967294)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject invalid ASN ranges', () => {
|
||||
expect(sampler.validateASN(-1)).toBe(false)
|
||||
expect(sampler.validateASN(4294967296)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRegionalDistribution', () => {
|
||||
it('should return 5 regions', () => {
|
||||
const distribution = sampler.getRegionalDistribution()
|
||||
expect(distribution.length).toBe(5)
|
||||
})
|
||||
|
||||
it('should have valid weight distribution', () => {
|
||||
const distribution = sampler.getRegionalDistribution()
|
||||
const totalWeight = distribution.reduce((sum, r) => sum + r.weight, 0)
|
||||
expect(totalWeight).toBeCloseTo(1.0, 2)
|
||||
})
|
||||
|
||||
it('should include APNIC as largest region', () => {
|
||||
const distribution = sampler.getRegionalDistribution()
|
||||
const apnic = distribution.find((r) => r.region === 'APNIC')
|
||||
expect(apnic).toBeDefined()
|
||||
expect(apnic?.weight).toBe(0.35)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,168 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ASPAAdoptionScheduler } from '../scheduler'
|
||||
|
||||
vi.mock('node-cron', () => ({
|
||||
default: {
|
||||
schedule: vi.fn(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../sampler', () => ({
|
||||
ASNSampler: vi.fn(() => ({
|
||||
sample: vi.fn(() => ({
|
||||
asns: [13335, 15169, 8452],
|
||||
totalSampled: 3,
|
||||
timestamp: new Date(),
|
||||
weightedByRegion: { APNIC: 2, RIPE: 1 },
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../collector', () => ({
|
||||
ASPACollector: vi.fn(() => ({
|
||||
collect: vi.fn().mockResolvedValue([
|
||||
{ asn: 13335, hasASPA: true, providers: [1, 2], region: 'APNIC' },
|
||||
{ asn: 15169, hasASPA: false, providers: [], region: 'APNIC' },
|
||||
{ asn: 8452, hasASPA: true, providers: [100], region: 'RIPE' },
|
||||
]),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../aggregator', () => ({
|
||||
AdoptionAggregator: vi.fn(() => ({
|
||||
aggregate: vi.fn(() => ({
|
||||
overall: {
|
||||
total: 3,
|
||||
withASPA: 2,
|
||||
coverage: 66.67,
|
||||
},
|
||||
regions: [
|
||||
{ region: 'APNIC', totalASNs: 2, ASNsWithASPA: 2, coveragePercentage: 100 },
|
||||
{ region: 'RIPE', totalASNs: 1, ASNsWithASPA: 1, coveragePercentage: 100 },
|
||||
],
|
||||
ixps: [],
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../forecaster', () => ({
|
||||
AdoptionForecaster: vi.fn(() => ({
|
||||
forecast: vi.fn(() => ({
|
||||
trend: 'up',
|
||||
trendStrength: 0.85,
|
||||
confidence: 0.92,
|
||||
predictedCoverage6m: 75.5,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../db-client', () => ({
|
||||
ASPADatabaseClient: vi.fn(() => ({
|
||||
recordAdoptionSnapshot: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
sampleDate: new Date(),
|
||||
}),
|
||||
recordRegionalBreakdown: vi.fn().mockResolvedValue([]),
|
||||
recordIXPBreakdown: vi.fn().mockResolvedValue([]),
|
||||
getAdoptionHistory: vi.fn().mockResolvedValue(
|
||||
Array.from({ length: 30 }, (_, i) => ({
|
||||
sampleDate: new Date(Date.now() - (30 - i) * 24 * 60 * 60 * 1000),
|
||||
coveragePercentage: 40 + i * 0.5,
|
||||
totalASNsSampled: 500,
|
||||
ASNsWithASPA: Math.floor((40 + i * 0.5) * 5),
|
||||
}))
|
||||
),
|
||||
getRegionalHistory: vi.fn().mockResolvedValue([]),
|
||||
getIXPHistory: vi.fn().mockResolvedValue([]),
|
||||
getLatestSnapshot: vi.fn().mockResolvedValue(null),
|
||||
getStatistics: vi.fn().mockResolvedValue({}),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('ASPAAdoptionScheduler', () => {
|
||||
let scheduler: ASPAAdoptionScheduler
|
||||
|
||||
beforeEach(() => {
|
||||
scheduler = new ASPAAdoptionScheduler()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('scheduler lifecycle', () => {
|
||||
it('should initialize without errors', () => {
|
||||
expect(scheduler).toBeDefined()
|
||||
expect(scheduler.isScheduled()).toBe(false)
|
||||
expect(scheduler.isJobRunning()).toBe(false)
|
||||
})
|
||||
|
||||
it('should start scheduler successfully', () => {
|
||||
scheduler.start()
|
||||
expect(scheduler.isScheduled()).toBe(true)
|
||||
})
|
||||
|
||||
it('should stop scheduler successfully', () => {
|
||||
scheduler.start()
|
||||
expect(scheduler.isScheduled()).toBe(true)
|
||||
|
||||
scheduler.stop()
|
||||
expect(scheduler.isScheduled()).toBe(false)
|
||||
})
|
||||
|
||||
it('should prevent starting scheduler twice', () => {
|
||||
scheduler.start()
|
||||
expect(scheduler.isScheduled()).toBe(true)
|
||||
|
||||
// Starting again should not create a new task
|
||||
scheduler.start()
|
||||
expect(scheduler.isScheduled()).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow manual job run', async () => {
|
||||
await scheduler.runManual()
|
||||
expect(scheduler.isJobRunning()).toBe(false) // Should complete
|
||||
})
|
||||
|
||||
it('should prevent concurrent job runs', async () => {
|
||||
// Set the isRunning flag to simulate a running job
|
||||
const promise1 = scheduler.runManual()
|
||||
|
||||
// Try to run again while first is still running
|
||||
let errorThrown = false
|
||||
try {
|
||||
await scheduler.runManual()
|
||||
} catch (error: unknown) {
|
||||
errorThrown = true
|
||||
expect(error instanceof Error && error.message).toContain('already running')
|
||||
}
|
||||
|
||||
await promise1
|
||||
expect(errorThrown).toBe(true)
|
||||
})
|
||||
|
||||
it('should indicate job running state correctly', async () => {
|
||||
expect(scheduler.isJobRunning()).toBe(false)
|
||||
|
||||
const runPromise = scheduler.runManual()
|
||||
// Job should be marked as running (or completing quickly)
|
||||
|
||||
await runPromise
|
||||
expect(scheduler.isJobRunning()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('job execution', () => {
|
||||
it('should execute daily job with all components', async () => {
|
||||
await scheduler.runManual()
|
||||
// If no errors thrown, job executed successfully
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,140 +0,0 @@
|
||||
import { AdoptionSnapshot, RegionalStats, IXPStats, AggregatorOptions } from './types'
|
||||
|
||||
export class AdoptionAggregator {
|
||||
aggregate(
|
||||
snapshots: AdoptionSnapshot[],
|
||||
options: AggregatorOptions = { includeRegions: true, includeIXPs: true }
|
||||
): {
|
||||
overall: { total: number; withASPA: number; coverage: number }
|
||||
regions: RegionalStats[]
|
||||
ixps: IXPStats[]
|
||||
} {
|
||||
const overall = this.calculateOverall(snapshots)
|
||||
const regions = options.includeRegions ? this.aggregateByRegion(snapshots) : []
|
||||
const ixps = options.includeIXPs ? this.aggregateByIXP(snapshots) : []
|
||||
|
||||
return { overall, regions, ixps }
|
||||
}
|
||||
|
||||
private calculateOverall(snapshots: AdoptionSnapshot[]): { total: number; withASPA: number; coverage: number } {
|
||||
const total = snapshots.length
|
||||
const withASPA = snapshots.filter((s) => s.hasASPA).length
|
||||
const coverage = total > 0 ? Math.round((withASPA / total) * 10000) / 100 : 0
|
||||
|
||||
return { total, withASPA, coverage }
|
||||
}
|
||||
|
||||
private aggregateByRegion(snapshots: AdoptionSnapshot[]): RegionalStats[] {
|
||||
const regionMap = new Map<string, AdoptionSnapshot[]>()
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
if (!regionMap.has(snapshot.region)) {
|
||||
regionMap.set(snapshot.region, [])
|
||||
}
|
||||
regionMap.get(snapshot.region)!.push(snapshot)
|
||||
}
|
||||
|
||||
const results: RegionalStats[] = []
|
||||
|
||||
for (const [region, snapshotsInRegion] of regionMap.entries()) {
|
||||
const total = snapshotsInRegion.length
|
||||
const withASPA = snapshotsInRegion.filter((s) => s.hasASPA).length
|
||||
const coverage = total > 0 ? Math.round((withASPA / total) * 10000) / 100 : 0
|
||||
|
||||
const topNetworks = this.getTopNetworks(snapshotsInRegion, 5)
|
||||
|
||||
results.push({
|
||||
region,
|
||||
totalASNs: total,
|
||||
ASNsWithASPA: withASPA,
|
||||
coveragePercentage: coverage,
|
||||
topNetworks,
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.coveragePercentage - a.coveragePercentage)
|
||||
}
|
||||
|
||||
private aggregateByIXP(snapshots: AdoptionSnapshot[]): IXPStats[] {
|
||||
// IXP membership data would come from PeeringDB in production
|
||||
// For now, simulate IXP participation based on AS numbers
|
||||
const ixpMap = new Map<number, AdoptionSnapshot[]>()
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
const ixpId = this.inferIXPMembership(snapshot.asn)
|
||||
if (ixpId > 0) {
|
||||
if (!ixpMap.has(ixpId)) {
|
||||
ixpMap.set(ixpId, [])
|
||||
}
|
||||
ixpMap.get(ixpId)!.push(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
const results: IXPStats[] = []
|
||||
|
||||
for (const [ixpId, snapshotsInIXP] of ixpMap.entries()) {
|
||||
const total = snapshotsInIXP.length
|
||||
const withASPA = snapshotsInIXP.filter((s) => s.hasASPA).length
|
||||
const coverage = total > 0 ? Math.round((withASPA / total) * 10000) / 100 : 0
|
||||
|
||||
results.push({
|
||||
ixpId,
|
||||
ixpName: this.getIXPName(ixpId),
|
||||
participantCount: total,
|
||||
participantsWithASPA: withASPA,
|
||||
coveragePercentage: coverage,
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.coveragePercentage - a.coveragePercentage)
|
||||
}
|
||||
|
||||
private getTopNetworks(
|
||||
snapshots: AdoptionSnapshot[],
|
||||
limit: number
|
||||
): Array<{ asn: number; name: string; providers: number }> {
|
||||
const withASPA = snapshots
|
||||
.filter((s) => s.hasASPA)
|
||||
.sort((a, b) => b.providers.length - a.providers.length)
|
||||
.slice(0, limit)
|
||||
|
||||
return withASPA.map((s) => ({
|
||||
asn: s.asn,
|
||||
name: this.getASNName(s.asn),
|
||||
providers: s.providers.length,
|
||||
}))
|
||||
}
|
||||
|
||||
private inferIXPMembership(asn: number): number {
|
||||
// Simulate IXP membership based on AS number ranges
|
||||
// In production, query PeeringDB for actual IXP membership
|
||||
if (asn % 100 === 0) return asn / 100
|
||||
return 0
|
||||
}
|
||||
|
||||
private getIXPName(ixpId: number): string {
|
||||
// Map IXP IDs to names - in production, query PeeringDB
|
||||
const ixpNames: Record<number, string> = {
|
||||
1: 'DE-CIX Frankfurt',
|
||||
2: 'AMS-IX Amsterdam',
|
||||
3: 'LINX London',
|
||||
4: 'JPNAP Tokyo',
|
||||
5: 'PCCW Hong Kong',
|
||||
}
|
||||
|
||||
return ixpNames[ixpId] || `IXP-${ixpId}`
|
||||
}
|
||||
|
||||
private getASNName(asn: number): string {
|
||||
// In production, query RIPE Stat or PeeringDB for actual AS names
|
||||
const asNames: Record<number, string> = {
|
||||
13335: 'Cloudflare',
|
||||
15169: 'Google',
|
||||
8452: 'Telenor',
|
||||
3352: 'Telefonica',
|
||||
1273: 'Vodafone',
|
||||
}
|
||||
|
||||
return asNames[asn] || `AS${asn}`
|
||||
}
|
||||
}
|
||||
@ -1,110 +0,0 @@
|
||||
import { ASPAObject, AdoptionSnapshot, CollectorOptions } from './types'
|
||||
|
||||
export class ASPACollector {
|
||||
private readonly ripeStatUrl = 'https://stat.ripe.net/api/v1'
|
||||
private readonly requestQueue: Array<() => Promise<void>> = []
|
||||
private isProcessing = false
|
||||
|
||||
collect(asns: number[], options: CollectorOptions = { maxRetries: 3, timeoutMs: 5000, rateLimit: 100 }): Promise<AdoptionSnapshot[]> {
|
||||
return this.collectWithRateLimit(asns, options)
|
||||
}
|
||||
|
||||
private async collectWithRateLimit(
|
||||
asns: number[],
|
||||
options: CollectorOptions
|
||||
): Promise<AdoptionSnapshot[]> {
|
||||
const snapshots: AdoptionSnapshot[] = []
|
||||
const delayMs = Math.ceil(1000 / options.rateLimit)
|
||||
|
||||
for (const asn of asns) {
|
||||
const snapshot = await this.fetchASPAForASN(asn, options)
|
||||
snapshots.push(snapshot)
|
||||
await this.delay(delayMs)
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
private async fetchASPAForASN(asn: number, options: CollectorOptions): Promise<AdoptionSnapshot> {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
|
||||
try {
|
||||
const providers = await this.queryASPAProviders(asn, options.timeoutMs)
|
||||
const hasASPA = providers.length > 0
|
||||
|
||||
return {
|
||||
asn,
|
||||
hasASPA,
|
||||
providers,
|
||||
region: this.inferRegion(asn),
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
if (attempt < options.maxRetries) {
|
||||
const delayMs = Math.pow(2, attempt) * 1000
|
||||
await this.delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
asn,
|
||||
hasASPA: false,
|
||||
providers: [],
|
||||
region: this.inferRegion(asn),
|
||||
}
|
||||
}
|
||||
|
||||
private async queryASPAProviders(asn: number, timeoutMs: number): Promise<number[]> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const url = `${this.ripeStatUrl}/as/${asn}/aspa`
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
throw new Error(`RIPE Stat API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
aspa?: Array<{ provider_asn: number }>
|
||||
}
|
||||
}
|
||||
const aspaData = data.data?.aspa || []
|
||||
|
||||
return Array.from(new Set(aspaData.map((obj) => obj.provider_asn)))
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
private inferRegion(asn: number): string {
|
||||
if (asn <= 1000) return 'RESERVED'
|
||||
if (asn <= 9999) return 'ARIN'
|
||||
if (asn <= 39999) return 'RIPE'
|
||||
if (asn <= 65534) return 'RIPENCC'
|
||||
if (asn <= 4199999999) return 'ARIN'
|
||||
if (asn <= 4294967294) return 'RESERVED'
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.requestQueue.length
|
||||
}
|
||||
}
|
||||
@ -1,262 +0,0 @@
|
||||
import { getDatabase } from '../../lib/db'
|
||||
import {
|
||||
AdoptionHistoryRecord,
|
||||
RegionalBreakdownRecord,
|
||||
IXPBreakdownRecord,
|
||||
RegionalStats,
|
||||
IXPStats,
|
||||
} from './types'
|
||||
|
||||
export class ASPADatabaseClient {
|
||||
async recordAdoptionSnapshot(
|
||||
sampleDate: Date,
|
||||
totalASNsSampled: number,
|
||||
ASNsWithASPA: number,
|
||||
regions: RegionalStats[],
|
||||
topAdopters: Array<{ asn: number; name: string; providers: number }>,
|
||||
metadata: Record<string, unknown> = {}
|
||||
): Promise<{ id: number; sampleDate: Date }> {
|
||||
const db = getDatabase()
|
||||
const coveragePercentage = totalASNsSampled > 0 ? (ASNsWithASPA / totalASNsSampled) * 100 : 0
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
INSERT INTO aspa_adoption_history
|
||||
(sample_date, sampled_at, total_asns_sampled, asns_with_aspa, coverage_percentage, top_adopters, regions, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (sample_date) DO UPDATE
|
||||
SET sampled_at = $2, total_asns_sampled = $3, asns_with_aspa = $4, coverage_percentage = $5, top_adopters = $6, regions = $7, metadata = $8
|
||||
RETURNING id, sample_date
|
||||
`,
|
||||
[
|
||||
sampleDate,
|
||||
new Date(),
|
||||
totalASNsSampled,
|
||||
ASNsWithASPA,
|
||||
coveragePercentage,
|
||||
JSON.stringify(topAdopters),
|
||||
JSON.stringify(regions),
|
||||
JSON.stringify(metadata),
|
||||
]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Failed to record ASPA adoption snapshot')
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.rows[0].id,
|
||||
sampleDate: new Date(result.rows[0].sample_date),
|
||||
}
|
||||
}
|
||||
|
||||
async recordRegionalBreakdown(
|
||||
sampleDate: Date,
|
||||
regions: RegionalStats[]
|
||||
): Promise<Array<{ id: number; region: string }>> {
|
||||
const db = getDatabase()
|
||||
const results: Array<{ id: number; region: string }> = []
|
||||
|
||||
for (const region of regions) {
|
||||
const result = await db.query(
|
||||
`
|
||||
INSERT INTO aspa_adoption_by_region
|
||||
(sample_date, region, total_asns, asns_with_aspa, coverage_percentage, top_networks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (sample_date, region) DO UPDATE
|
||||
SET total_asns = $3, asns_with_aspa = $4, coverage_percentage = $5, top_networks = $6
|
||||
RETURNING id, region
|
||||
`,
|
||||
[
|
||||
sampleDate,
|
||||
region.region,
|
||||
region.totalASNs,
|
||||
region.ASNsWithASPA,
|
||||
region.coveragePercentage,
|
||||
JSON.stringify(region.topNetworks),
|
||||
]
|
||||
)
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
results.push({
|
||||
id: result.rows[0].id,
|
||||
region: result.rows[0].region,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async recordIXPBreakdown(sampleDate: Date, ixps: IXPStats[]): Promise<Array<{ id: number; ixpName: string }>> {
|
||||
const db = getDatabase()
|
||||
const results: Array<{ id: number; ixpName: string }> = []
|
||||
|
||||
for (const ixp of ixps) {
|
||||
const result = await db.query(
|
||||
`
|
||||
INSERT INTO aspa_adoption_by_ixp
|
||||
(sample_date, ixp_id, ixp_name, participant_count, participants_with_aspa, coverage_percentage)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (sample_date, ixp_id) DO UPDATE
|
||||
SET participant_count = $4, participants_with_aspa = $5, coverage_percentage = $6
|
||||
RETURNING id, ixp_name
|
||||
`,
|
||||
[sampleDate, ixp.ixpId, ixp.ixpName, ixp.participantCount, ixp.participantsWithASPA, ixp.coveragePercentage]
|
||||
)
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
results.push({
|
||||
id: result.rows[0].id,
|
||||
ixpName: result.rows[0].ixp_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async getAdoptionHistory(days: number = 90): Promise<AdoptionHistoryRecord[]> {
|
||||
const db = getDatabase()
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days)
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, sample_date, sampled_at, total_asns_sampled, asns_with_aspa, coverage_percentage,
|
||||
top_adopters, regions, sample_method, metadata
|
||||
FROM aspa_adoption_history
|
||||
WHERE sample_date >= $1
|
||||
ORDER BY sample_date DESC
|
||||
LIMIT 100
|
||||
`,
|
||||
[cutoffDate]
|
||||
)
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
sampleDate: new Date(row.sample_date),
|
||||
sampledAt: new Date(row.sampled_at),
|
||||
totalASNsSampled: row.total_asns_sampled,
|
||||
ASNsWithASPA: row.asns_with_aspa,
|
||||
coveragePercentage: row.coverage_percentage,
|
||||
adoptionRateChange: undefined,
|
||||
topAdopters: JSON.parse(row.top_adopters || '[]'),
|
||||
regions: JSON.parse(row.regions || '[]'),
|
||||
sampleMethod: row.sample_method,
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
}))
|
||||
}
|
||||
|
||||
async getRegionalHistory(region: string, days: number = 90): Promise<RegionalBreakdownRecord[]> {
|
||||
const db = getDatabase()
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days)
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, sample_date, region, total_asns, asns_with_aspa, coverage_percentage, top_networks
|
||||
FROM aspa_adoption_by_region
|
||||
WHERE region = $1 AND sample_date >= $2
|
||||
ORDER BY sample_date DESC
|
||||
LIMIT 100
|
||||
`,
|
||||
[region, cutoffDate]
|
||||
)
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
sampleDate: new Date(row.sample_date),
|
||||
region: row.region,
|
||||
totalASNs: row.total_asns,
|
||||
ASNsWithASPA: row.asns_with_aspa,
|
||||
coveragePercentage: row.coverage_percentage,
|
||||
topNetworks: JSON.parse(row.top_networks || '[]'),
|
||||
}))
|
||||
}
|
||||
|
||||
async getIXPHistory(ixpId: number, days: number = 90): Promise<IXPBreakdownRecord[]> {
|
||||
const db = getDatabase()
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days)
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, sample_date, ixp_id, ixp_name, participant_count, participants_with_aspa, coverage_percentage
|
||||
FROM aspa_adoption_by_ixp
|
||||
WHERE ixp_id = $1 AND sample_date >= $2
|
||||
ORDER BY sample_date DESC
|
||||
LIMIT 100
|
||||
`,
|
||||
[ixpId, cutoffDate]
|
||||
)
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
sampleDate: new Date(row.sample_date),
|
||||
ixpId: row.ixp_id,
|
||||
ixpName: row.ixp_name,
|
||||
participantCount: row.participant_count,
|
||||
participantsWithASPA: row.participants_with_aspa,
|
||||
coveragePercentage: row.coverage_percentage,
|
||||
}))
|
||||
}
|
||||
|
||||
async getLatestSnapshot(): Promise<AdoptionHistoryRecord | null> {
|
||||
const db = getDatabase()
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, sample_date, sampled_at, total_asns_sampled, asns_with_aspa, coverage_percentage,
|
||||
top_adopters, regions, sample_method, metadata
|
||||
FROM aspa_adoption_history
|
||||
ORDER BY sample_date DESC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) return null
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.id,
|
||||
sampleDate: new Date(row.sample_date),
|
||||
sampledAt: new Date(row.sampled_at),
|
||||
totalASNsSampled: row.total_asns_sampled,
|
||||
ASNsWithASPA: row.asns_with_aspa,
|
||||
coveragePercentage: row.coverage_percentage,
|
||||
adoptionRateChange: undefined,
|
||||
topAdopters: JSON.parse(row.top_adopters || '[]'),
|
||||
regions: JSON.parse(row.regions || '[]'),
|
||||
sampleMethod: row.sample_method,
|
||||
metadata: JSON.parse(row.metadata || '{}'),
|
||||
}
|
||||
}
|
||||
|
||||
async getStatistics(): Promise<{
|
||||
totalSnapshots: number
|
||||
daysOfData: number
|
||||
averageCoverage: number
|
||||
currentCoverage: number
|
||||
}> {
|
||||
const db = getDatabase()
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_snapshots,
|
||||
COUNT(DISTINCT DATE(sample_date)) as days_of_data,
|
||||
ROUND(AVG(coverage_percentage)::numeric, 2) as average_coverage,
|
||||
(SELECT coverage_percentage FROM aspa_adoption_history ORDER BY sample_date DESC LIMIT 1) as current_coverage
|
||||
FROM aspa_adoption_history
|
||||
`)
|
||||
|
||||
const row = result.rows[0]
|
||||
|
||||
return {
|
||||
totalSnapshots: parseInt(row.total_snapshots, 10),
|
||||
daysOfData: parseInt(row.days_of_data, 10),
|
||||
averageCoverage: parseFloat(row.average_coverage || '0'),
|
||||
currentCoverage: parseFloat(row.current_coverage || '0'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,132 +0,0 @@
|
||||
import { ForecastData, ForecasterOptions } from './types'
|
||||
|
||||
interface TimeSeriesPoint {
|
||||
date: Date
|
||||
coverage: number
|
||||
}
|
||||
|
||||
export class AdoptionForecaster {
|
||||
forecast(
|
||||
historicalData: TimeSeriesPoint[],
|
||||
options: ForecasterOptions = { historyDays: 90, forecastMonths: 6, regressionAlpha: 0.05 }
|
||||
): ForecastData {
|
||||
if (historicalData.length < 2) {
|
||||
return {
|
||||
predictedCoverage6m: 0,
|
||||
confidence: 0,
|
||||
trend: 'stable',
|
||||
trendStrength: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...historicalData].sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
const regression = this.linearRegression(sorted)
|
||||
const trend = this.calculateTrend(regression.slope)
|
||||
const trendStrength = Math.abs(regression.slope) / 100
|
||||
|
||||
const daysForecast = options.forecastMonths * 30
|
||||
const forecastDate = new Date()
|
||||
forecastDate.setDate(forecastDate.getDate() + daysForecast)
|
||||
|
||||
const predictedCoverage = this.predictCoverage(regression, daysForecast)
|
||||
const confidence = this.calculateConfidence(regression, sorted, options.regressionAlpha)
|
||||
|
||||
return {
|
||||
predictedCoverage6m: Math.min(100, Math.max(0, Math.round(predictedCoverage * 100) / 100)),
|
||||
confidence: Math.round(confidence * 10000) / 10000,
|
||||
trend,
|
||||
trendStrength,
|
||||
}
|
||||
}
|
||||
|
||||
private linearRegression(data: TimeSeriesPoint[]): { slope: number; intercept: number; rSquared: number } {
|
||||
const n = data.length
|
||||
let sumX = 0
|
||||
let sumY = 0
|
||||
let sumXY = 0
|
||||
let sumX2 = 0
|
||||
let sumY2 = 0
|
||||
|
||||
const baseDate = data[0].date.getTime()
|
||||
|
||||
for (const point of data) {
|
||||
const x = (point.date.getTime() - baseDate) / (1000 * 60 * 60 * 24)
|
||||
const y = point.coverage
|
||||
|
||||
sumX += x
|
||||
sumY += y
|
||||
sumXY += x * y
|
||||
sumX2 += x * x
|
||||
sumY2 += y * y
|
||||
}
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX)
|
||||
const intercept = (sumY - slope * sumX) / n
|
||||
|
||||
const ssTotal = sumY2 - (sumY * sumY) / n
|
||||
const ssResidual = sumY2 - intercept * sumY - slope * sumXY
|
||||
const rSquared = ssTotal > 0 ? 1 - ssResidual / ssTotal : 0
|
||||
|
||||
return { slope, intercept, rSquared }
|
||||
}
|
||||
|
||||
private predictCoverage(regression: { slope: number; intercept: number }, daysAhead: number): number {
|
||||
return regression.intercept + regression.slope * daysAhead
|
||||
}
|
||||
|
||||
private calculateTrend(slope: number): 'up' | 'down' | 'stable' {
|
||||
if (slope > 0.1) return 'up'
|
||||
if (slope < -0.1) return 'down'
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
private calculateConfidence(
|
||||
regression: { slope: number; intercept: number; rSquared: number },
|
||||
data: TimeSeriesPoint[],
|
||||
alpha: number
|
||||
): number {
|
||||
const rSquaredConfidence = regression.rSquared
|
||||
const dataPointConfidence = Math.min(1, data.length / 90)
|
||||
const trendConfidence = Math.max(0.5, Math.min(1, 1 - alpha))
|
||||
|
||||
return (rSquaredConfidence * 0.4 + dataPointConfidence * 0.35 + trendConfidence * 0.25)
|
||||
}
|
||||
|
||||
calculateMovingAverage(data: TimeSeriesPoint[], windowDays: number): TimeSeriesPoint[] {
|
||||
const result: TimeSeriesPoint[] = []
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const windowStart = data[i].date.getTime() - windowDays * 24 * 60 * 60 * 1000
|
||||
const windowData = data.filter((p) => p.date.getTime() >= windowStart && p.date.getTime() <= data[i].date.getTime())
|
||||
|
||||
if (windowData.length > 0) {
|
||||
const avgCoverage = windowData.reduce((sum, p) => sum + p.coverage, 0) / windowData.length
|
||||
result.push({
|
||||
date: data[i].date,
|
||||
coverage: Math.round(avgCoverage * 100) / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
detectAnomalies(
|
||||
data: TimeSeriesPoint[],
|
||||
stdDevThreshold: number = 2
|
||||
): Array<{ date: Date; coverage: number; deviation: number }> {
|
||||
if (data.length < 3) return []
|
||||
|
||||
const mean = data.reduce((sum, p) => sum + p.coverage, 0) / data.length
|
||||
const variance = data.reduce((sum, p) => sum + Math.pow(p.coverage - mean, 2), 0) / data.length
|
||||
const stdDev = Math.sqrt(variance)
|
||||
|
||||
return data
|
||||
.filter((p) => Math.abs(p.coverage - mean) > stdDevThreshold * stdDev)
|
||||
.map((p) => ({
|
||||
date: p.date,
|
||||
coverage: p.coverage,
|
||||
deviation: (p.coverage - mean) / stdDev,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
import { SamplingResult, SamplerOptions } from './types'
|
||||
|
||||
interface RegionalDistribution {
|
||||
region: string
|
||||
asnCount: number
|
||||
weight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Stratified random sampling of ASNs weighted by regional distribution
|
||||
* Global AS number space: 0-4294967295 (2^32 - 1)
|
||||
* Private: 64512-65534, 4200000000-4294967294
|
||||
*/
|
||||
export class ASNSampler {
|
||||
private readonly regions: RegionalDistribution[] = [
|
||||
{ region: 'APNIC', asnCount: 180000, weight: 0.35 },
|
||||
{ region: 'RIPE', asnCount: 150000, weight: 0.28 },
|
||||
{ region: 'ARIN', asnCount: 140000, weight: 0.26 },
|
||||
{ region: 'LACNIC', asnCount: 45000, weight: 0.08 },
|
||||
{ region: 'AFRINIC', asnCount: 20000, weight: 0.03 },
|
||||
]
|
||||
|
||||
private publicASNRanges = [
|
||||
{ min: 1, max: 64511 },
|
||||
{ min: 65535, max: 4199999999 },
|
||||
{ min: 4294967295, max: 4294967295 },
|
||||
]
|
||||
|
||||
sample(targetCount: number, options: SamplerOptions = { stratifiedByRegion: true, minASNsPerRegion: 50 }): SamplingResult {
|
||||
const timestamp = new Date()
|
||||
|
||||
if (options.stratifiedByRegion) {
|
||||
return this.stratifiedSample(targetCount, options)
|
||||
}
|
||||
|
||||
return this.simpleRandomSample(targetCount, timestamp)
|
||||
}
|
||||
|
||||
private stratifiedSample(targetCount: number, options: SamplerOptions): SamplingResult {
|
||||
const timestamp = new Date()
|
||||
const asns = new Set<number>()
|
||||
const weightedByRegion: Record<string, number> = {}
|
||||
|
||||
for (const region of this.regions) {
|
||||
const regionCount = Math.max(
|
||||
options.minASNsPerRegion!,
|
||||
Math.floor(targetCount * region.weight)
|
||||
)
|
||||
weightedByRegion[region.region] = regionCount
|
||||
|
||||
for (let i = 0; i < regionCount && asns.size < targetCount; i++) {
|
||||
const asn = this.generateRandomASN(options.seed)
|
||||
asns.add(asn)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
asns: Array.from(asns),
|
||||
totalSampled: asns.size,
|
||||
weightedByRegion,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
private simpleRandomSample(targetCount: number, timestamp: Date): SamplingResult {
|
||||
const asns = new Set<number>()
|
||||
|
||||
while (asns.size < targetCount) {
|
||||
const asn = this.generateRandomASN()
|
||||
asns.add(asn)
|
||||
}
|
||||
|
||||
return {
|
||||
asns: Array.from(asns),
|
||||
totalSampled: asns.size,
|
||||
weightedByRegion: {},
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
private generateRandomASN(seed?: number): number {
|
||||
let random: number
|
||||
|
||||
if (seed !== undefined) {
|
||||
// Deterministic pseudo-random with seed
|
||||
const x = Math.sin(seed) * 10000
|
||||
random = x - Math.floor(x)
|
||||
} else {
|
||||
random = Math.random()
|
||||
}
|
||||
|
||||
const rangeIndex = Math.floor(random * this.publicASNRanges.length)
|
||||
const range = this.publicASNRanges[rangeIndex]
|
||||
const rangeSize = range.max - range.min + 1
|
||||
|
||||
const asn = Math.floor(range.min + random * rangeSize)
|
||||
|
||||
// Skip private ASN ranges
|
||||
if (asn >= 64512 && asn <= 65534) {
|
||||
return this.generateRandomASN()
|
||||
}
|
||||
if (asn >= 4200000000 && asn <= 4294967294) {
|
||||
return this.generateRandomASN()
|
||||
}
|
||||
|
||||
return asn
|
||||
}
|
||||
|
||||
getRegionalDistribution(): RegionalDistribution[] {
|
||||
return [...this.regions]
|
||||
}
|
||||
|
||||
validateASN(asn: number): boolean {
|
||||
if (asn < 0 || asn > 4294967295) return false
|
||||
if (asn >= 64512 && asn <= 65534) return false
|
||||
if (asn >= 4200000000 && asn <= 4294967294) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -1,154 +0,0 @@
|
||||
import cron from 'node-cron'
|
||||
import { ASNSampler } from './sampler'
|
||||
import { ASPACollector } from './collector'
|
||||
import { AdoptionAggregator } from './aggregator'
|
||||
import { AdoptionForecaster } from './forecaster'
|
||||
import { ASPADatabaseClient } from './db-client'
|
||||
|
||||
export class ASPAAdoptionScheduler {
|
||||
private task: cron.ScheduledTask | null = null
|
||||
private sampler = new ASNSampler()
|
||||
private collector = new ASPACollector()
|
||||
private aggregator = new AdoptionAggregator()
|
||||
private forecaster = new AdoptionForecaster()
|
||||
private dbClient = new ASPADatabaseClient()
|
||||
private isRunning = false
|
||||
|
||||
/**
|
||||
* Start the daily ASPA adoption sampling job
|
||||
* Runs at 2 AM UTC every day (0 2 * * *)
|
||||
*/
|
||||
start(): void {
|
||||
if (this.task) {
|
||||
console.log('[ASPA Scheduler] Job already scheduled')
|
||||
return
|
||||
}
|
||||
|
||||
this.task = cron.schedule('0 2 * * *', () => {
|
||||
if (!this.isRunning) {
|
||||
this.runDailyJob().catch((error) => {
|
||||
console.error('[ASPA Scheduler] Daily job failed:', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[ASPA Scheduler] Scheduled daily job at 2:00 AM UTC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduled job
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.task) {
|
||||
this.task.stop()
|
||||
this.task.destroy()
|
||||
this.task = null
|
||||
console.log('[ASPA Scheduler] Job stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger the daily job (useful for testing)
|
||||
*/
|
||||
async runManual(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('Job is already running')
|
||||
}
|
||||
|
||||
await this.runDailyJob()
|
||||
}
|
||||
|
||||
private async runDailyJob(): Promise<void> {
|
||||
this.isRunning = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
console.log('[ASPA Scheduler] Starting daily adoption snapshot')
|
||||
|
||||
const sampleDate = new Date()
|
||||
sampleDate.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// Step 1: Stratified sample of ASNs
|
||||
console.log('[ASPA Scheduler] Sampling ASNs...')
|
||||
const samplingResult = this.sampler.sample(750, {
|
||||
stratifiedByRegion: true,
|
||||
minASNsPerRegion: 100,
|
||||
})
|
||||
console.log(`[ASPA Scheduler] Sampled ${samplingResult.totalSampled} ASNs`)
|
||||
|
||||
// Step 2: Collect ASPA data for sampled ASNs
|
||||
console.log('[ASPA Scheduler] Collecting ASPA data...')
|
||||
const snapshots = await this.collector.collect(samplingResult.asns, {
|
||||
maxRetries: 3,
|
||||
timeoutMs: 5000,
|
||||
rateLimit: 100,
|
||||
})
|
||||
console.log(`[ASPA Scheduler] Collected ASPA data for ${snapshots.length} ASNs`)
|
||||
|
||||
// Step 3: Aggregate by region and IXP
|
||||
console.log('[ASPA Scheduler] Aggregating data by region and IXP...')
|
||||
const { overall, regions, ixps } = this.aggregator.aggregate(snapshots, {
|
||||
includeRegions: true,
|
||||
includeIXPs: true,
|
||||
})
|
||||
console.log(
|
||||
`[ASPA Scheduler] Aggregation complete: ${overall.withASPA}/${overall.total} ASNs with ASPA (${overall.coverage}%)`
|
||||
)
|
||||
|
||||
// Step 4: Fetch historical data and forecast
|
||||
console.log('[ASPA Scheduler] Calculating forecast...')
|
||||
const history = await this.dbClient.getAdoptionHistory(90)
|
||||
const timeSeriesData = history
|
||||
.reverse()
|
||||
.map((h) => ({
|
||||
date: h.sampleDate,
|
||||
coverage: h.coveragePercentage,
|
||||
}))
|
||||
const forecast = this.forecaster.forecast(timeSeriesData, {
|
||||
historyDays: 90,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
console.log(`[ASPA Scheduler] Forecast: ${forecast.predictedCoverage6m}% coverage in 6 months (confidence: ${forecast.confidence})`)
|
||||
|
||||
// Step 5: Store results in database
|
||||
console.log('[ASPA Scheduler] Storing results...')
|
||||
const topAdopters = snapshots
|
||||
.filter((s) => s.hasASPA)
|
||||
.sort((a, b) => b.providers.length - a.providers.length)
|
||||
.slice(0, 10)
|
||||
.map((s) => ({
|
||||
asn: s.asn,
|
||||
name: `AS${s.asn}`,
|
||||
providers: s.providers.length,
|
||||
}))
|
||||
|
||||
const metadata = {
|
||||
samplingMethod: 'stratified-regional',
|
||||
forecastData: forecast,
|
||||
dataCollectionTimeMs: Date.now() - startTime,
|
||||
}
|
||||
|
||||
await this.dbClient.recordAdoptionSnapshot(sampleDate, overall.total, overall.withASPA, regions, topAdopters, metadata)
|
||||
await this.dbClient.recordRegionalBreakdown(sampleDate, regions)
|
||||
await this.dbClient.recordIXPBreakdown(sampleDate, ixps)
|
||||
|
||||
console.log(`[ASPA Scheduler] Daily job completed in ${Date.now() - startTime}ms`)
|
||||
} catch (error) {
|
||||
console.error('[ASPA Scheduler] Job failed:', error instanceof Error ? error.message : String(error))
|
||||
throw error
|
||||
} finally {
|
||||
this.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
isJobRunning(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
|
||||
isScheduled(): boolean {
|
||||
return this.task !== null
|
||||
}
|
||||
}
|
||||
|
||||
export const aspaScheduler = new ASPAAdoptionScheduler()
|
||||
@ -1,124 +0,0 @@
|
||||
/**
|
||||
* ASPA Adoption Tracker - Type Definitions
|
||||
* BGP-ASPA (Autonomous System Provider Authorization) adoption tracking
|
||||
*/
|
||||
|
||||
export interface ASPAObject {
|
||||
customer_asn: number
|
||||
provider_asn: number
|
||||
afi: number
|
||||
}
|
||||
|
||||
export interface SamplingResult {
|
||||
asns: number[]
|
||||
totalSampled: number
|
||||
weightedByRegion: Record<string, number>
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface AdoptionSnapshot {
|
||||
asn: number
|
||||
hasASPA: boolean
|
||||
providers: number[]
|
||||
region: string
|
||||
}
|
||||
|
||||
export interface RegionalStats {
|
||||
region: string
|
||||
totalASNs: number
|
||||
ASNsWithASPA: number
|
||||
coveragePercentage: number
|
||||
topNetworks: Array<{ asn: number; name: string; providers: number }>
|
||||
}
|
||||
|
||||
export interface IXPStats {
|
||||
ixpId: number
|
||||
ixpName: string
|
||||
participantCount: number
|
||||
participantsWithASPA: number
|
||||
coveragePercentage: number
|
||||
}
|
||||
|
||||
export interface ForecastData {
|
||||
predictedCoverage6m: number
|
||||
confidence: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
trendStrength: number
|
||||
}
|
||||
|
||||
export interface AdoptionStats {
|
||||
current: {
|
||||
coverage: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
change24h: number
|
||||
}
|
||||
trend: Array<{
|
||||
date: string
|
||||
coveragePercentage: number
|
||||
sampledASNs: number
|
||||
}>
|
||||
forecast: ForecastData
|
||||
regions: RegionalStats[]
|
||||
topAdopters: Array<{
|
||||
asn: number
|
||||
name: string
|
||||
providers: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AdoptionHistoryRecord {
|
||||
id: number
|
||||
sampleDate: Date
|
||||
sampledAt: Date
|
||||
totalASNsSampled: number
|
||||
ASNsWithASPA: number
|
||||
coveragePercentage: number
|
||||
adoptionRateChange?: number
|
||||
topAdopters: Array<{ asn: number; name: string; providers: number }>
|
||||
regions: RegionalStats[]
|
||||
sampleMethod?: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RegionalBreakdownRecord {
|
||||
id: number
|
||||
sampleDate: Date
|
||||
region: string
|
||||
totalASNs: number
|
||||
ASNsWithASPA: number
|
||||
coveragePercentage: number
|
||||
topNetworks: Array<{ asn: number; name: string; providers: number }>
|
||||
}
|
||||
|
||||
export interface IXPBreakdownRecord {
|
||||
id: number
|
||||
sampleDate: Date
|
||||
ixpId: number
|
||||
ixpName: string
|
||||
participantCount: number
|
||||
participantsWithASPA: number
|
||||
coveragePercentage: number
|
||||
}
|
||||
|
||||
export interface SamplerOptions {
|
||||
stratifiedByRegion: boolean
|
||||
minASNsPerRegion: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
export interface CollectorOptions {
|
||||
maxRetries: number
|
||||
timeoutMs: number
|
||||
rateLimit: number
|
||||
}
|
||||
|
||||
export interface AggregatorOptions {
|
||||
includeRegions: boolean
|
||||
includeIXPs: boolean
|
||||
}
|
||||
|
||||
export interface ForecasterOptions {
|
||||
historyDays: number
|
||||
forecastMonths: number
|
||||
regressionAlpha: number
|
||||
}
|
||||
@ -1,632 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { HijackAlertsDatabaseClient } from '../db-client'
|
||||
import type { HijackEvent, WebhookSubscription, WebhookDelivery, CreateHijackEventInput, CreateWebhookInput } from '../types'
|
||||
|
||||
describe('HijackAlertsDatabaseClient', () => {
|
||||
let client: HijackAlertsDatabaseClient
|
||||
let mockPool: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockPool = {
|
||||
query: vi.fn(),
|
||||
}
|
||||
client = new HijackAlertsDatabaseClient(mockPool)
|
||||
})
|
||||
|
||||
describe('insertHijackEvent', () => {
|
||||
it('should insert a hijack event and return the mapped result', async () => {
|
||||
const input: CreateHijackEventInput = {
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336, 13337],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'CRITICAL',
|
||||
description: 'Multiple origin ASes detected',
|
||||
details: { evidence: 'BGP announcement' },
|
||||
}
|
||||
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336, 13337],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'CRITICAL',
|
||||
description: 'Multiple origin ASes detected',
|
||||
details: { evidence: 'BGP announcement' },
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.insertHijackEvent(input)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO hijack_events'),
|
||||
expect.arrayContaining([
|
||||
13335,
|
||||
'1.1.1.0/24',
|
||||
13335,
|
||||
[13336, 13337],
|
||||
'MOAS',
|
||||
'CRITICAL',
|
||||
'Multiple origin ASes detected',
|
||||
])
|
||||
)
|
||||
expect(result).toEqual({
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: expect.any(Date),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336, 13337],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'CRITICAL',
|
||||
description: 'Multiple origin ASes detected',
|
||||
details: { evidence: 'BGP announcement' },
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: expect.any(Date),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle different hijack types', async () => {
|
||||
const testCases: Array<CreateHijackEventInput['hijack_type']> = ['MOAS', 'HIJACK', 'LEAK']
|
||||
|
||||
for (const hijackType of testCases) {
|
||||
mockPool.query.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
asn: 15169,
|
||||
prefix: '8.8.8.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 15169,
|
||||
detected_asns: [15170],
|
||||
hijack_type: hijackType,
|
||||
severity: 'HIGH',
|
||||
description: `${hijackType} detected`,
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const input: CreateHijackEventInput = {
|
||||
asn: 15169,
|
||||
prefix: '8.8.8.0/24',
|
||||
expected_asn: 15169,
|
||||
detected_asns: [15170],
|
||||
hijack_type: hijackType,
|
||||
severity: 'HIGH',
|
||||
description: `${hijackType} detected`,
|
||||
details: {},
|
||||
}
|
||||
|
||||
const result = await client.insertHijackEvent(input)
|
||||
expect(result.hijack_type).toBe(hijackType)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentHijackEvent', () => {
|
||||
it('should return a recent hijack event if found', async () => {
|
||||
const mockRow = {
|
||||
id: 5,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'MOAS detected',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.getRecentHijackEvent(13335, '1.1.1.0/24', 6)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM hijack_events'),
|
||||
expect.arrayContaining([13335, '1.1.1.0/24'])
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe(5)
|
||||
expect(result?.asn).toBe(13335)
|
||||
})
|
||||
|
||||
it('should return null if no recent hijack event found', async () => {
|
||||
mockPool.query.mockResolvedValue({ rows: [] })
|
||||
|
||||
const result = await client.getRecentHijackEvent(13335, '1.1.1.0/24', 6)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should use default 6 hours lookback', async () => {
|
||||
mockPool.query.mockResolvedValue({ rows: [] })
|
||||
|
||||
await client.getRecentHijackEvent(13335, '1.1.1.0/24')
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("INTERVAL '6 hours'"),
|
||||
expect.any(Array)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHijacksByAsn', () => {
|
||||
it('should return hijacks with pagination', async () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event 1',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
mockPool.query
|
||||
.mockResolvedValueOnce({ rows: mockRows })
|
||||
.mockResolvedValueOnce({ rows: [{ total: '10' }] })
|
||||
|
||||
const result = await client.getHijacksByAsn(13335, 50, 0)
|
||||
|
||||
expect(result.events).toHaveLength(1)
|
||||
expect(result.total).toBe(10)
|
||||
expect(result.events[0].asn).toBe(13335)
|
||||
})
|
||||
|
||||
it('should filter by resolved status', async () => {
|
||||
mockPool.query
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ total: '0' }] })
|
||||
|
||||
await client.getHijacksByAsn(13335, 50, 0, true)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('AND resolved = $2'),
|
||||
expect.arrayContaining([13335, true])
|
||||
)
|
||||
})
|
||||
|
||||
it('should return empty results', async () => {
|
||||
mockPool.query
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ total: '0' }] })
|
||||
|
||||
const result = await client.getHijacksByAsn(99999, 50, 0)
|
||||
|
||||
expect(result.events).toHaveLength(0)
|
||||
expect(result.total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHijack', () => {
|
||||
it('should mark hijack as resolved', async () => {
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: true,
|
||||
resolved_at: '2024-04-15T11:00:00Z',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.resolveHijack(1, 'False positive')
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE hijack_events SET resolved = true'),
|
||||
[1]
|
||||
)
|
||||
expect(result.resolved).toBe(true)
|
||||
expect(result.resolved_at).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWebhookSubscription', () => {
|
||||
it('should create a webhook subscription', async () => {
|
||||
const input: CreateWebhookInput = {
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
timeout_ms: 5000,
|
||||
max_retries: 5,
|
||||
}
|
||||
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 5,
|
||||
timeout_ms: 5000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.createWebhookSubscription(input, 'sk_test_123')
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO webhook_subscriptions'),
|
||||
expect.arrayContaining([13335, 'https://example.com/webhook', 'sk_test_123', 5000, 5])
|
||||
)
|
||||
expect(result.id).toBe(1)
|
||||
expect(result.asn).toBe(13335)
|
||||
expect(result.active).toBe(true)
|
||||
})
|
||||
|
||||
it('should use default values for optional parameters', async () => {
|
||||
const input: CreateWebhookInput = {
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
}
|
||||
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
await client.createWebhookSubscription(input, 'sk_test_123')
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([13335, 'https://example.com/webhook', 'sk_test_123', 10000, 3]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWebhooksByAsn', () => {
|
||||
it('should return webhooks for an ASN', async () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
},
|
||||
]
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: mockRows })
|
||||
|
||||
const result = await client.getWebhooksByAsn(13335)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM webhook_subscriptions'),
|
||||
[13335]
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].asn).toBe(13335)
|
||||
})
|
||||
|
||||
it('should return empty array if no webhooks found', async () => {
|
||||
mockPool.query.mockResolvedValue({ rows: [] })
|
||||
|
||||
const result = await client.getWebhooksByAsn(99999)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWebhookSubscription', () => {
|
||||
it('should return a webhook subscription by ID', async () => {
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.getWebhookSubscription(1)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe(1)
|
||||
})
|
||||
|
||||
it('should return null if webhook not found', async () => {
|
||||
mockPool.query.mockResolvedValue({ rows: [] })
|
||||
|
||||
const result = await client.getWebhookSubscription(99999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWebhookSubscription', () => {
|
||||
it('should delete a webhook subscription', async () => {
|
||||
mockPool.query.mockResolvedValue({})
|
||||
|
||||
await client.deleteWebhookSubscription(1)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith('DELETE FROM webhook_subscriptions WHERE id = $1', [1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateWebhookStatus', () => {
|
||||
it('should update webhook active status', async () => {
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: false,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.updateWebhookStatus(1, false)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE webhook_subscriptions SET active = $1'),
|
||||
[false, 1]
|
||||
)
|
||||
expect(result?.active).toBe(false)
|
||||
})
|
||||
|
||||
it('should return null if webhook not found', async () => {
|
||||
mockPool.query.mockResolvedValue({ rows: [] })
|
||||
|
||||
const result = await client.updateWebhookStatus(99999, true)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordWebhookDelivery', () => {
|
||||
it('should record successful webhook delivery', async () => {
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: '2024-04-15T10:00:00Z',
|
||||
response_status: 200,
|
||||
response_body: 'OK',
|
||||
error_message: null,
|
||||
next_retry_at: null,
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.recordWebhookDelivery(1, 1, 1, 200, 'OK', null)
|
||||
|
||||
expect(result.response_status).toBe(200)
|
||||
expect(result.next_retry_at).toBeNull()
|
||||
})
|
||||
|
||||
it('should record failed webhook delivery with retry time', async () => {
|
||||
const mockRow = {
|
||||
id: 2,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: '2024-04-15T10:00:00Z',
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Internal Server Error',
|
||||
next_retry_at: '2024-04-15T10:02:00Z',
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.recordWebhookDelivery(1, 1, 1, 500, null, 'Internal Server Error')
|
||||
|
||||
expect(result.response_status).toBe(500)
|
||||
expect(result.next_retry_at).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFailedDeliveriesForRetry', () => {
|
||||
it('should return failed deliveries ready for retry', async () => {
|
||||
const mockRows = [
|
||||
{
|
||||
ws_id: 1,
|
||||
ws_asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
ws_created_at: '2024-04-15T10:00:00Z',
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
he_id: 1,
|
||||
he_asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
he_created_at: '2024-04-15T10:00:00Z',
|
||||
wd_id: 1,
|
||||
wd_subscription_id: 1,
|
||||
wd_event_id: 1,
|
||||
wd_attempt_number: 1,
|
||||
wd_sent_at: '2024-04-15T10:00:00Z',
|
||||
wd_response_status: 500,
|
||||
wd_response_body: null,
|
||||
wd_error_message: 'Error',
|
||||
wd_next_retry_at: '2024-04-15T10:02:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: mockRows })
|
||||
|
||||
const result = await client.getFailedDeliveriesForRetry()
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].delivery.response_status).toBe(500)
|
||||
expect(result[0].webhook.asn).toBe(13335)
|
||||
expect(result[0].event.asn).toBe(13335)
|
||||
})
|
||||
|
||||
it('should return empty array if no failed deliveries', async () => {
|
||||
mockPool.query.mockResolvedValue({ rows: [] })
|
||||
|
||||
const result = await client.getFailedDeliveriesForRetry()
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateWebhookLastTriggered', () => {
|
||||
it('should update webhook last triggered time and failure count', async () => {
|
||||
mockPool.query.mockResolvedValue({})
|
||||
|
||||
await client.updateWebhookLastTriggered(1, 0)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE webhook_subscriptions SET last_triggered_at = NOW()'),
|
||||
[0, 1]
|
||||
)
|
||||
})
|
||||
|
||||
it('should increment failure count on retry', async () => {
|
||||
mockPool.query.mockResolvedValue({})
|
||||
|
||||
await client.updateWebhookLastTriggered(1, 2)
|
||||
|
||||
expect(mockPool.query).toHaveBeenCalledWith(expect.any(String), [2, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('row mapping', () => {
|
||||
it('should correctly map database row to HijackEvent', async () => {
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'CRITICAL',
|
||||
description: 'Test event',
|
||||
details: { test: true },
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.insertHijackEvent({
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'CRITICAL',
|
||||
description: 'Test event',
|
||||
details: { test: true },
|
||||
})
|
||||
|
||||
expect(result.detected_at).toBeInstanceOf(Date)
|
||||
expect(result.created_at).toBeInstanceOf(Date)
|
||||
expect(result.resolved_at).toBeNull()
|
||||
})
|
||||
|
||||
it('should correctly map database row with resolved timestamp', async () => {
|
||||
const mockRow = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: '2024-04-15T10:00:00Z',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: true,
|
||||
resolved_at: '2024-04-15T11:00:00Z',
|
||||
created_at: '2024-04-15T10:00:00Z',
|
||||
}
|
||||
|
||||
mockPool.query.mockResolvedValue({ rows: [mockRow] })
|
||||
|
||||
const result = await client.insertHijackEvent({
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
})
|
||||
|
||||
expect(result.resolved_at).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,251 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { checkForHijacks, classifyHijack, enrichHijackDescriptionWithOllama } from '../detector'
|
||||
import type { HijackAlertsDatabaseClient } from '../db-client'
|
||||
|
||||
describe('Hijack Detector', () => {
|
||||
let mockDbClient: Partial<HijackAlertsDatabaseClient>
|
||||
|
||||
beforeEach(() => {
|
||||
mockDbClient = {
|
||||
getRecentHijackEvent: vi.fn().mockResolvedValue(null),
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('checkForHijacks', () => {
|
||||
it('should return error for invalid ASN:prefix format', async () => {
|
||||
const result = await checkForHijacks('invalid-format', mockDbClient as HijackAlertsDatabaseClient)
|
||||
expect(result[0]?.detected).toBe(false)
|
||||
expect(result[0]?.reason).toContain('Invalid ASN:prefix format')
|
||||
})
|
||||
|
||||
it('should return error for non-numeric ASN', async () => {
|
||||
const result = await checkForHijacks('abc:185.1.0.0/24', mockDbClient as HijackAlertsDatabaseClient)
|
||||
expect(result[0]?.detected).toBe(false)
|
||||
expect(result[0]?.reason).toContain('Invalid ASN number')
|
||||
})
|
||||
|
||||
it('should check for recent events to avoid duplicates', async () => {
|
||||
mockDbClient.getRecentHijackEvent = vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '185.1.0.0/24',
|
||||
})
|
||||
|
||||
const result = await checkForHijacks('13335:185.1.0.0/24', mockDbClient as HijackAlertsDatabaseClient)
|
||||
|
||||
expect(mockDbClient.getRecentHijackEvent).toHaveBeenCalledWith(13335, '185.1.0.0/24', 6)
|
||||
expect(result[0]?.detected).toBe(false)
|
||||
expect(result[0]?.reason).toContain('Recently detected')
|
||||
})
|
||||
|
||||
it('should return non-detected for valid input with no hijack', async () => {
|
||||
const result = await checkForHijacks('13335:185.1.0.0/24', mockDbClient as HijackAlertsDatabaseClient)
|
||||
expect(result[0]?.detected).toBe(false)
|
||||
expect(result[0]?.reason).toBe('No hijack detected')
|
||||
})
|
||||
|
||||
it('should parse ASN and prefix correctly', async () => {
|
||||
const result = await checkForHijacks('15169:8.8.8.0/24', mockDbClient as HijackAlertsDatabaseClient)
|
||||
expect(mockDbClient.getRecentHijackEvent).toHaveBeenCalledWith(15169, '8.8.8.0/24', expect.any(Number))
|
||||
})
|
||||
|
||||
it('should handle deduplication with various time windows', async () => {
|
||||
mockDbClient.getRecentHijackEvent = vi.fn().mockResolvedValue(null)
|
||||
|
||||
const result = await checkForHijacks('13335:185.1.0.0/24', mockDbClient as HijackAlertsDatabaseClient)
|
||||
expect(result[0]?.detected).toBe(false)
|
||||
|
||||
expect(mockDbClient.getRecentHijackEvent).toHaveBeenCalledWith(13335, '185.1.0.0/24', 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyHijack', () => {
|
||||
it('should classify MOAS when expected ASN is in detected list with multiple origins', () => {
|
||||
const result = classifyHijack(13335, [13335, 8451], '185.1.0.0/24')
|
||||
|
||||
expect(result.hijack_type).toBe('MOAS')
|
||||
expect(result.severity).toBe('MEDIUM')
|
||||
expect(result.baseDescription).toContain('Multiple Origin AS')
|
||||
})
|
||||
|
||||
it('should classify HIJACK when completely unauthorized ASN detected', () => {
|
||||
const result = classifyHijack(13335, [64512], '185.1.0.0/24')
|
||||
|
||||
expect(result.hijack_type).toBe('HIJACK')
|
||||
expect(result.severity).toBe('CRITICAL') // /24 prefix
|
||||
expect(result.baseDescription).toContain('Unauthorized ASN')
|
||||
})
|
||||
|
||||
it('should classify LEAK for private ASN prefix leak', () => {
|
||||
const result = classifyHijack(65000, [65001], '10.0.0.0/8')
|
||||
|
||||
expect(result.hijack_type).toBe('LEAK')
|
||||
expect(result.severity).toBe('MEDIUM')
|
||||
expect(result.baseDescription).toContain('prefix leak')
|
||||
})
|
||||
|
||||
it('should set CRITICAL severity for /24 prefix hijack', () => {
|
||||
const result = classifyHijack(13335, [64512], '185.1.0.0/24')
|
||||
|
||||
expect(result.severity).toBe('CRITICAL')
|
||||
})
|
||||
|
||||
it('should set HIGH severity for /16 prefix hijack', () => {
|
||||
const result = classifyHijack(13335, [64512], '185.1.0.0/16')
|
||||
|
||||
expect(result.severity).toBe('HIGH')
|
||||
})
|
||||
|
||||
it('should handle empty detected_asns array', () => {
|
||||
const result = classifyHijack(13335, [], '185.1.0.0/24')
|
||||
|
||||
expect(result.hijack_type).toBe('HIJACK')
|
||||
expect(result.severity).toBe('HIGH')
|
||||
expect(result.baseDescription).toContain('no origin ASN detected')
|
||||
})
|
||||
|
||||
it('should classify hijack for /32 (most specific)', () => {
|
||||
const result = classifyHijack(13335, [64512], '1.2.3.4/32')
|
||||
|
||||
expect(result.hijack_type).toBe('HIJACK')
|
||||
expect(result.severity).toBe('CRITICAL')
|
||||
})
|
||||
|
||||
it('should classify hijack for /8 as HIGH (less specific)', () => {
|
||||
const result = classifyHijack(13335, [64512], '185.0.0.0/8')
|
||||
|
||||
expect(result.hijack_type).toBe('HIJACK')
|
||||
expect(result.severity).toBe('HIGH')
|
||||
})
|
||||
|
||||
it('should handle prefix without CIDR notation', () => {
|
||||
const result = classifyHijack(13335, [64512], '185.1.0.0')
|
||||
|
||||
expect(result.hijack_type).toBe('HIJACK')
|
||||
expect(result.severity).toBe('HIGH') // No /X means prefixLength = 0
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrichHijackDescriptionWithOllama', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return base description when Ollama is unavailable', async () => {
|
||||
const baseDesc = 'Unauthorized ASN detected'
|
||||
const result = await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
|
||||
expect(result).toBe(baseDesc)
|
||||
})
|
||||
|
||||
it('should return enriched description when Ollama responds', async () => {
|
||||
const baseDesc = 'Unauthorized ASN detected'
|
||||
const enrichedText = 'This is a critical security incident.'
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ response: enrichedText })
|
||||
})
|
||||
|
||||
const result = await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
|
||||
expect(result).toContain(baseDesc)
|
||||
expect(result).toContain('AI Analysis:')
|
||||
expect(result).toContain(enrichedText)
|
||||
})
|
||||
|
||||
it('should fallback to base description on fetch error', async () => {
|
||||
const baseDesc = 'Unauthorized ASN detected'
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const result = await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
|
||||
expect(result).toBe(baseDesc)
|
||||
})
|
||||
|
||||
it('should fallback on Ollama HTTP error', async () => {
|
||||
const baseDesc = 'Unauthorized ASN detected'
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500
|
||||
})
|
||||
|
||||
const result = await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
|
||||
expect(result).toBe(baseDesc)
|
||||
})
|
||||
|
||||
it('should timeout after 5 seconds', async () => {
|
||||
const baseDesc = 'Unauthorized ASN detected'
|
||||
|
||||
global.fetch = vi.fn().mockImplementation((url: string, options: RequestInit) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const abortHandler = () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resolve({ ok: true, json: async () => ({ response: 'slow response' }) })
|
||||
}, 10000)
|
||||
|
||||
// Listen for abort signal
|
||||
if (options.signal instanceof AbortSignal) {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeoutId)
|
||||
abortHandler()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
expect(result).toBe(baseDesc)
|
||||
// AbortController timeout should trigger around 5-6 seconds
|
||||
expect(elapsed).toBeGreaterThanOrEqual(5000)
|
||||
expect(elapsed).toBeLessThan(7000)
|
||||
}, { timeout: 10000 })
|
||||
|
||||
it('should use custom OLLAMA_URL from env', async () => {
|
||||
const baseDesc = 'Test'
|
||||
const customUrl = 'http://custom-ollama:11434'
|
||||
process.env.OLLAMA_URL = customUrl
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ response: 'enriched' })
|
||||
})
|
||||
|
||||
await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(customUrl),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
delete process.env.OLLAMA_URL
|
||||
})
|
||||
|
||||
it('should handle empty Ollama response', async () => {
|
||||
const baseDesc = 'Unauthorized ASN detected'
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
})
|
||||
|
||||
const result = await enrichHijackDescriptionWithOllama(baseDesc, '185.1.0.0/24', [64512])
|
||||
|
||||
expect(result).toBe(baseDesc)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,198 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import nock from 'nock'
|
||||
import { HijackAlertsDatabaseClient } from '../db-client'
|
||||
import { WebhookClient } from '../webhook-client'
|
||||
import { RetryScheduler } from '../retry-scheduler'
|
||||
|
||||
describe('Hijack Alerts Integration', () => {
|
||||
let mockDbClient: Partial<HijackAlertsDatabaseClient>
|
||||
let webhookClient: WebhookClient
|
||||
|
||||
beforeEach(() => {
|
||||
webhookClient = new WebhookClient()
|
||||
mockDbClient = {
|
||||
getFailedDeliveriesForRetry: vi.fn().mockResolvedValue([]),
|
||||
recordWebhookDelivery: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date(),
|
||||
response_status: 200,
|
||||
response_body: null,
|
||||
error_message: null,
|
||||
next_retry_at: null,
|
||||
}),
|
||||
updateWebhookStatus: vi.fn().mockResolvedValue(null),
|
||||
updateWebhookLastTriggered: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
describe('End-to-end webhook delivery', () => {
|
||||
it('should send webhook and record delivery', async () => {
|
||||
const event = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '185.1.0.0/24',
|
||||
detected_at: new Date(),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [12345],
|
||||
hijack_type: 'HIJACK' as const,
|
||||
severity: 'HIGH' as const,
|
||||
description: 'BGP hijack detected',
|
||||
details: { source: 'test' },
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date(),
|
||||
}
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.reply(200)
|
||||
|
||||
const result = await webhookClient.sendWebhook(event, 'https://webhook.example.com/notify', 'secret_key', 5000)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle retry scheduler with failed deliveries', async () => {
|
||||
const failedDeliveries = [
|
||||
{
|
||||
webhook: {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://webhook.example.com/notify',
|
||||
secret_key: 'secret_key',
|
||||
created_at: new Date(),
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 5000,
|
||||
metadata: {},
|
||||
},
|
||||
event: {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '185.1.0.0/24',
|
||||
detected_at: new Date(),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [12345],
|
||||
hijack_type: 'HIJACK' as const,
|
||||
severity: 'HIGH' as const,
|
||||
description: 'BGP hijack',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date(),
|
||||
},
|
||||
delivery: {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date(),
|
||||
response_status: null,
|
||||
response_body: null,
|
||||
error_message: 'Timeout',
|
||||
next_retry_at: new Date(),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
;(mockDbClient.getFailedDeliveriesForRetry as any).mockResolvedValue(failedDeliveries)
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.reply(200)
|
||||
|
||||
const scheduler = new RetryScheduler(mockDbClient as HijackAlertsDatabaseClient, webhookClient)
|
||||
|
||||
// Note: This tests the scheduler logic directly, not the cron scheduling
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockDbClient.recordWebhookDelivery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Webhook signature validation', () => {
|
||||
it('should generate consistent signatures', () => {
|
||||
const payload = JSON.stringify({ test: 'data' })
|
||||
const secret = 'test_secret'
|
||||
|
||||
const sig1 = (webhookClient as any).generateSignature(payload, secret)
|
||||
const sig2 = (webhookClient as any).generateSignature(payload, secret)
|
||||
|
||||
expect(sig1).toBe(sig2)
|
||||
})
|
||||
|
||||
it('should generate different signatures for different secrets', () => {
|
||||
const payload = JSON.stringify({ test: 'data' })
|
||||
|
||||
const sig1 = (webhookClient as any).generateSignature(payload, 'secret1')
|
||||
const sig2 = (webhookClient as any).generateSignature(payload, 'secret2')
|
||||
|
||||
expect(sig1).not.toBe(sig2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle network errors gracefully', async () => {
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.replyWithError('Network error')
|
||||
|
||||
const event = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '185.1.0.0/24',
|
||||
detected_at: new Date(),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [12345],
|
||||
hijack_type: 'HIJACK' as const,
|
||||
severity: 'HIGH' as const,
|
||||
description: 'BGP hijack',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date(),
|
||||
}
|
||||
|
||||
const result = await webhookClient.sendWebhook(event, 'https://webhook.example.com/notify', 'secret', 5000)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should track response times', async () => {
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.reply(200)
|
||||
|
||||
const event = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '185.1.0.0/24',
|
||||
detected_at: new Date(),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [12345],
|
||||
hijack_type: 'HIJACK' as const,
|
||||
severity: 'HIGH' as const,
|
||||
description: 'BGP hijack',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date(),
|
||||
}
|
||||
|
||||
const result = await webhookClient.sendWebhook(event, 'https://webhook.example.com/notify', 'secret', 5000)
|
||||
|
||||
expect(result.response_time_ms).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,497 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { RetryScheduler } from '../retry-scheduler'
|
||||
import type { HijackAlertsDatabaseClient } from '../db-client'
|
||||
import type { WebhookClient } from '../webhook-client'
|
||||
import type { HijackEvent, WebhookSubscription, WebhookDelivery } from '../types'
|
||||
|
||||
describe('RetryScheduler', () => {
|
||||
let scheduler: RetryScheduler
|
||||
let mockDbClient: any
|
||||
let mockWebhookClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbClient = {
|
||||
getFailedDeliveriesForRetry: vi.fn(),
|
||||
recordWebhookDelivery: vi.fn(),
|
||||
updateWebhookLastTriggered: vi.fn(),
|
||||
updateWebhookStatus: vi.fn(),
|
||||
}
|
||||
|
||||
mockWebhookClient = {
|
||||
sendWebhook: vi.fn(),
|
||||
}
|
||||
|
||||
scheduler = new RetryScheduler(mockDbClient, mockWebhookClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop()
|
||||
})
|
||||
|
||||
describe('scheduler lifecycle', () => {
|
||||
it('should start scheduler without errors', () => {
|
||||
expect(() => scheduler.start()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should stop scheduler without errors', () => {
|
||||
scheduler.start()
|
||||
expect(() => scheduler.stop()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should prevent double start', () => {
|
||||
scheduler.start()
|
||||
const firstStart = (scheduler as any).cronJob
|
||||
scheduler.start()
|
||||
const secondStart = (scheduler as any).cronJob
|
||||
|
||||
expect(firstStart).toBe(secondStart)
|
||||
})
|
||||
|
||||
it('should allow restart after stop', () => {
|
||||
scheduler.start()
|
||||
scheduler.stop()
|
||||
expect((scheduler as any).cronJob).toBeNull()
|
||||
expect(() => scheduler.start()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('processFailedDeliveries', () => {
|
||||
it('should process empty failed deliveries list', async () => {
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([])
|
||||
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockDbClient.recordWebhookDelivery).not.toHaveBeenCalled()
|
||||
expect(mockWebhookClient.sendWebhook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should retry failed webhook delivery successfully', async () => {
|
||||
const mockEvent: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'CRITICAL',
|
||||
description: 'MOAS detected',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockWebhook: WebhookSubscription = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 1,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockDelivery: WebhookDelivery = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Server error',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([
|
||||
{
|
||||
webhook: mockWebhook,
|
||||
event: mockEvent,
|
||||
delivery: mockDelivery,
|
||||
},
|
||||
])
|
||||
|
||||
mockWebhookClient.sendWebhook.mockResolvedValue({
|
||||
success: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
})
|
||||
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockWebhookClient.sendWebhook).toHaveBeenCalledWith(
|
||||
mockEvent,
|
||||
'https://example.com/webhook',
|
||||
'sk_test_123',
|
||||
10000
|
||||
)
|
||||
expect(mockDbClient.recordWebhookDelivery).toHaveBeenCalledWith(
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
200,
|
||||
null,
|
||||
null
|
||||
)
|
||||
expect(mockDbClient.updateWebhookLastTriggered).toHaveBeenCalledWith(1, 0)
|
||||
})
|
||||
|
||||
it('should increment failure count on retry failure', async () => {
|
||||
const mockEvent: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockWebhook: WebhookSubscription = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 1,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockDelivery: WebhookDelivery = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Server error',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([
|
||||
{
|
||||
webhook: mockWebhook,
|
||||
event: mockEvent,
|
||||
delivery: mockDelivery,
|
||||
},
|
||||
])
|
||||
|
||||
mockWebhookClient.sendWebhook.mockResolvedValue({
|
||||
success: false,
|
||||
status: 500,
|
||||
error: 'Server error',
|
||||
})
|
||||
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockDbClient.recordWebhookDelivery).toHaveBeenCalledWith(
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
500,
|
||||
null,
|
||||
'Server error'
|
||||
)
|
||||
expect(mockDbClient.updateWebhookLastTriggered).toHaveBeenCalledWith(1, 2)
|
||||
})
|
||||
|
||||
it('should disable webhook after max retries exceeded', async () => {
|
||||
const mockEvent: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockWebhook: WebhookSubscription = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 3,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockDelivery: WebhookDelivery = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 2,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Server error',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([
|
||||
{
|
||||
webhook: mockWebhook,
|
||||
event: mockEvent,
|
||||
delivery: mockDelivery,
|
||||
},
|
||||
])
|
||||
|
||||
mockWebhookClient.sendWebhook.mockResolvedValue({
|
||||
success: false,
|
||||
status: 500,
|
||||
error: 'Server error',
|
||||
})
|
||||
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockDbClient.updateWebhookStatus).toHaveBeenCalledWith(1, false)
|
||||
})
|
||||
|
||||
it('should process multiple failed deliveries', async () => {
|
||||
const mockEvent1: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event 1',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockEvent2: HijackEvent = {
|
||||
id: 2,
|
||||
asn: 15169,
|
||||
prefix: '8.8.8.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 15169,
|
||||
detected_asns: [15170],
|
||||
hijack_type: 'HIJACK',
|
||||
severity: 'CRITICAL',
|
||||
description: 'Event 2',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockWebhook1: WebhookSubscription = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook1',
|
||||
secret_key: 'sk_test_1',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 1,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockWebhook2: WebhookSubscription = {
|
||||
id: 2,
|
||||
asn: 15169,
|
||||
endpoint_url: 'https://example.com/webhook2',
|
||||
secret_key: 'sk_test_2',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 2,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockDelivery1: WebhookDelivery = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Error 1',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
const mockDelivery2: WebhookDelivery = {
|
||||
id: 2,
|
||||
subscription_id: 2,
|
||||
event_id: 2,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 502,
|
||||
response_body: null,
|
||||
error_message: 'Error 2',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([
|
||||
{ webhook: mockWebhook1, event: mockEvent1, delivery: mockDelivery1 },
|
||||
{ webhook: mockWebhook2, event: mockEvent2, delivery: mockDelivery2 },
|
||||
])
|
||||
|
||||
mockWebhookClient.sendWebhook.mockResolvedValue({
|
||||
success: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
})
|
||||
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockWebhookClient.sendWebhook).toHaveBeenCalledTimes(2)
|
||||
expect(mockDbClient.recordWebhookDelivery).toHaveBeenCalledTimes(2)
|
||||
expect(mockDbClient.updateWebhookLastTriggered).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle errors in webhook processing', async () => {
|
||||
const mockEvent: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockWebhook: WebhookSubscription = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 1,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 10000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockDelivery: WebhookDelivery = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Error',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([
|
||||
{ webhook: mockWebhook, event: mockEvent, delivery: mockDelivery },
|
||||
])
|
||||
|
||||
mockWebhookClient.sendWebhook.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await expect(
|
||||
(scheduler as any).processFailedDeliveries()
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should respect webhook timeout setting', async () => {
|
||||
const mockEvent: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '1.1.1.0/24',
|
||||
detected_at: new Date('2024-04-15T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [13336],
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'HIGH',
|
||||
description: 'Event',
|
||||
details: {},
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
}
|
||||
|
||||
const mockWebhook: WebhookSubscription = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
endpoint_url: 'https://example.com/webhook',
|
||||
secret_key: 'sk_test_123',
|
||||
created_at: new Date('2024-04-15T10:00:00Z'),
|
||||
last_triggered_at: null,
|
||||
failure_count: 0,
|
||||
active: true,
|
||||
max_retries: 3,
|
||||
timeout_ms: 5000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
const mockDelivery: WebhookDelivery = {
|
||||
id: 1,
|
||||
subscription_id: 1,
|
||||
event_id: 1,
|
||||
attempt_number: 1,
|
||||
sent_at: new Date('2024-04-15T10:00:00Z'),
|
||||
response_status: 500,
|
||||
response_body: null,
|
||||
error_message: 'Error',
|
||||
next_retry_at: new Date('2024-04-15T10:02:00Z'),
|
||||
}
|
||||
|
||||
mockDbClient.getFailedDeliveriesForRetry.mockResolvedValue([
|
||||
{ webhook: mockWebhook, event: mockEvent, delivery: mockDelivery },
|
||||
])
|
||||
|
||||
mockWebhookClient.sendWebhook.mockResolvedValue({
|
||||
success: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
})
|
||||
|
||||
await (scheduler as any).processFailedDeliveries()
|
||||
|
||||
expect(mockWebhookClient.sendWebhook).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
5000
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,147 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import nock from 'nock'
|
||||
import { WebhookClient } from '../webhook-client'
|
||||
import type { HijackEvent } from '../types'
|
||||
|
||||
describe('WebhookClient', () => {
|
||||
let client: WebhookClient
|
||||
const testEvent: HijackEvent = {
|
||||
id: 1,
|
||||
asn: 13335,
|
||||
prefix: '185.1.0.0/24',
|
||||
detected_at: new Date('2026-04-29T10:00:00Z'),
|
||||
expected_asn: 13335,
|
||||
detected_asns: [12345],
|
||||
hijack_type: 'HIJACK',
|
||||
severity: 'HIGH',
|
||||
description: 'Potential BGP hijack detected',
|
||||
details: { source: 'bgp_monitor', confidence: 0.95 },
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date('2026-04-29T10:00:00Z'),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client = new WebhookClient()
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
describe('sendWebhook', () => {
|
||||
it('should send webhook with correct headers', async () => {
|
||||
const endpoint = 'https://webhook.example.com/notify'
|
||||
const secret = 'test_secret_key'
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify', (body) => {
|
||||
return typeof body === 'object' && 'event' in body && 'timestamp' in body
|
||||
})
|
||||
.reply(200, { success: true })
|
||||
|
||||
const result = await client.sendWebhook(testEvent, endpoint, secret, 5000)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.response_time_ms).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should include HMAC signature in headers', async () => {
|
||||
const endpoint = 'https://webhook.example.com/notify'
|
||||
const secret = 'test_secret_key'
|
||||
let capturedHeaders: Record<string, string | string[]> | undefined
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.reply(200, function () {
|
||||
capturedHeaders = this.req.getHeaders()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
await client.sendWebhook(testEvent, endpoint, secret, 5000)
|
||||
|
||||
expect(capturedHeaders).toBeDefined()
|
||||
expect(capturedHeaders?.['x-webhook-signature']).toBeDefined()
|
||||
expect(typeof capturedHeaders?.['x-webhook-signature']).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle timeout errors', async () => {
|
||||
const endpoint = 'https://webhook.example.com/notify'
|
||||
const secret = 'test_secret_key'
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.delayConnection(10000)
|
||||
.reply(200)
|
||||
|
||||
const result = await client.sendWebhook(testEvent, endpoint, secret, 100)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return error on 5xx responses', async () => {
|
||||
const endpoint = 'https://webhook.example.com/notify'
|
||||
const secret = 'test_secret_key'
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.reply(500, { error: 'Internal Server Error' })
|
||||
|
||||
const result = await client.sendWebhook(testEvent, endpoint, secret, 5000)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should succeed on 2xx responses', async () => {
|
||||
const endpoint = 'https://webhook.example.com/notify'
|
||||
const secret = 'test_secret_key'
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.reply(201, { id: 'evt_123' })
|
||||
|
||||
const result = await client.sendWebhook(testEvent, endpoint, secret, 5000)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.status).toBe(201)
|
||||
})
|
||||
|
||||
it('should return error on network failure', async () => {
|
||||
const endpoint = 'https://webhook.example.com/notify'
|
||||
const secret = 'test_secret_key'
|
||||
|
||||
nock('https://webhook.example.com')
|
||||
.post('/notify')
|
||||
.replyWithError('ECONNREFUSED')
|
||||
|
||||
const result = await client.sendWebhook(testEvent, endpoint, secret, 5000)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateBackoffDelay', () => {
|
||||
it('should calculate exponential backoff', () => {
|
||||
expect(client.calculateBackoffDelay(1)).toBe(1000)
|
||||
expect(client.calculateBackoffDelay(2)).toBe(2000)
|
||||
expect(client.calculateBackoffDelay(3)).toBe(4000)
|
||||
expect(client.calculateBackoffDelay(4)).toBe(8000)
|
||||
expect(client.calculateBackoffDelay(5)).toBe(16000)
|
||||
})
|
||||
|
||||
it('should cap delay at maximum', () => {
|
||||
expect(client.calculateBackoffDelay(6)).toBe(32000)
|
||||
expect(client.calculateBackoffDelay(10)).toBe(32000)
|
||||
})
|
||||
|
||||
it('should handle attempt number 0 correctly', () => {
|
||||
expect(client.calculateBackoffDelay(0)).toBe(0.5)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,180 +0,0 @@
|
||||
import { Pool } from 'pg'
|
||||
import type { HijackEvent, WebhookSubscription, WebhookDelivery, CreateHijackEventInput, CreateWebhookInput } from './types'
|
||||
|
||||
export class HijackAlertsDatabaseClient {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async insertHijackEvent(input: CreateHijackEventInput): Promise<HijackEvent> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO hijack_events (asn, prefix, expected_asn, detected_asns, hijack_type, severity, description, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[input.asn, input.prefix, input.expected_asn, input.detected_asns, input.hijack_type, input.severity, input.description, JSON.stringify(input.details)]
|
||||
)
|
||||
return this.mapRow(result.rows[0])
|
||||
}
|
||||
|
||||
async getRecentHijackEvent(asn: number, prefix: string, hoursBack: number = 6): Promise<HijackEvent | null> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT * FROM hijack_events
|
||||
WHERE asn = $1 AND prefix = $2 AND detected_at > NOW() - INTERVAL '${hoursBack} hours'
|
||||
ORDER BY detected_at DESC LIMIT 1`,
|
||||
[asn, prefix]
|
||||
)
|
||||
return result.rows.length > 0 ? this.mapRow(result.rows[0]) : null
|
||||
}
|
||||
|
||||
async getHijacksByAsn(asn: number, limit: number = 50, offset: number = 0, resolved?: boolean): Promise<{ total: number; events: HijackEvent[] }> {
|
||||
let query = 'SELECT * FROM hijack_events WHERE asn = $1'
|
||||
const params: unknown[] = [asn]
|
||||
|
||||
if (resolved !== undefined) {
|
||||
query += ` AND resolved = $${params.length + 1}`
|
||||
params.push(resolved)
|
||||
}
|
||||
|
||||
query += ' ORDER BY detected_at DESC LIMIT $' + (params.length + 1) + ' OFFSET $' + (params.length + 2)
|
||||
params.push(limit, offset)
|
||||
|
||||
const [eventsResult, countResult] = await Promise.all([
|
||||
this.pool.query(query, params),
|
||||
this.pool.query('SELECT COUNT(*) as total FROM hijack_events WHERE asn = $1', [asn])
|
||||
])
|
||||
|
||||
return {
|
||||
total: parseInt(countResult.rows[0].total),
|
||||
events: eventsResult.rows.map((row) => this.mapRow(row))
|
||||
}
|
||||
}
|
||||
|
||||
async resolveHijack(event_id: number, resolution_notes: string): Promise<HijackEvent> {
|
||||
const result = await this.pool.query(
|
||||
`UPDATE hijack_events SET resolved = true, resolved_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[event_id]
|
||||
)
|
||||
return this.mapRow(result.rows[0])
|
||||
}
|
||||
|
||||
async createWebhookSubscription(input: CreateWebhookInput, secretKey: string): Promise<WebhookSubscription> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO webhook_subscriptions (asn, endpoint_url, secret_key, timeout_ms, max_retries)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[input.asn, input.endpoint_url, secretKey, input.timeout_ms || 10000, input.max_retries || 3]
|
||||
)
|
||||
return this.mapWebhookRow(result.rows[0])
|
||||
}
|
||||
|
||||
async getWebhooksByAsn(asn: number): Promise<WebhookSubscription[]> {
|
||||
const result = await this.pool.query('SELECT * FROM webhook_subscriptions WHERE asn = $1 AND active = true', [asn])
|
||||
return result.rows.map((row) => this.mapWebhookRow(row))
|
||||
}
|
||||
|
||||
async getWebhookSubscription(webhook_id: number): Promise<WebhookSubscription | null> {
|
||||
const result = await this.pool.query('SELECT * FROM webhook_subscriptions WHERE id = $1', [webhook_id])
|
||||
return result.rows.length > 0 ? this.mapWebhookRow(result.rows[0]) : null
|
||||
}
|
||||
|
||||
async deleteWebhookSubscription(webhook_id: number): Promise<void> {
|
||||
await this.pool.query('DELETE FROM webhook_subscriptions WHERE id = $1', [webhook_id])
|
||||
}
|
||||
|
||||
async updateWebhookStatus(webhook_id: number, active: boolean): Promise<WebhookSubscription | null> {
|
||||
const result = await this.pool.query('UPDATE webhook_subscriptions SET active = $1 WHERE id = $2 RETURNING *', [active, webhook_id])
|
||||
return result.rows.length > 0 ? this.mapWebhookRow(result.rows[0]) : null
|
||||
}
|
||||
|
||||
async recordWebhookDelivery(subscription_id: number, event_id: number, attempt: number, status: number | null, body: string | null, error: string | null): Promise<WebhookDelivery> {
|
||||
const nextRetryAt = status && status >= 400 ? new Date(Date.now() + Math.pow(2, attempt) * 1000) : null
|
||||
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO webhook_deliveries (subscription_id, event_id, attempt_number, response_status, response_body, error_message, next_retry_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[subscription_id, event_id, attempt, status, body, error, nextRetryAt]
|
||||
)
|
||||
return this.mapDeliveryRow(result.rows[0])
|
||||
}
|
||||
|
||||
async getFailedDeliveriesForRetry(): Promise<Array<{ webhook: WebhookSubscription; event: HijackEvent; delivery: WebhookDelivery }>> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT
|
||||
ws.id as ws_id, ws.asn as ws_asn, ws.endpoint_url, ws.secret_key, ws.created_at as ws_created_at,
|
||||
ws.last_triggered_at, ws.failure_count, ws.active, ws.max_retries, ws.timeout_ms, ws.metadata,
|
||||
he.id as he_id, he.asn as he_asn, he.prefix, he.detected_at, he.expected_asn, he.detected_asns,
|
||||
he.hijack_type, he.severity, he.description, he.details, he.resolved, he.resolved_at, he.created_at as he_created_at,
|
||||
wd.id as wd_id, wd.subscription_id as wd_subscription_id, wd.event_id as wd_event_id, wd.attempt_number as wd_attempt_number,
|
||||
wd.sent_at as wd_sent_at, wd.response_status as wd_response_status, wd.response_body as wd_response_body,
|
||||
wd.error_message as wd_error_message, wd.next_retry_at as wd_next_retry_at
|
||||
FROM webhook_deliveries wd
|
||||
JOIN webhook_subscriptions ws ON wd.subscription_id = ws.id
|
||||
JOIN hijack_events he ON wd.event_id = he.id
|
||||
WHERE wd.next_retry_at <= NOW() AND wd.attempt_number < ws.max_retries AND ws.active = true
|
||||
ORDER BY wd.next_retry_at ASC
|
||||
LIMIT 1000`
|
||||
)
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
webhook: this.mapWebhookRow(row, 'ws_'),
|
||||
event: this.mapRow(row, 'he_'),
|
||||
delivery: this.mapDeliveryRow(row, 'wd_')
|
||||
}))
|
||||
}
|
||||
|
||||
async updateWebhookLastTriggered(webhook_id: number, failure_count: number = 0): Promise<void> {
|
||||
await this.pool.query('UPDATE webhook_subscriptions SET last_triggered_at = NOW(), failure_count = $1 WHERE id = $2', [failure_count, webhook_id])
|
||||
}
|
||||
|
||||
private mapRow(row: Record<string, unknown>, prefix: string = ''): HijackEvent {
|
||||
const key = (k: string) => (prefix ? `${prefix}${k}` : k)
|
||||
return {
|
||||
id: row[key('id')] as number,
|
||||
asn: row[key('asn')] as number,
|
||||
prefix: row[key('prefix')] as string,
|
||||
detected_at: new Date(row[key('detected_at')] as string),
|
||||
expected_asn: row[key('expected_asn')] as number,
|
||||
detected_asns: row[key('detected_asns')] as number[],
|
||||
hijack_type: row[key('hijack_type')] as 'MOAS' | 'HIJACK' | 'LEAK',
|
||||
severity: row[key('severity')] as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW',
|
||||
description: row[key('description')] as string,
|
||||
details: row[key('details')] as Record<string, unknown>,
|
||||
resolved: row[key('resolved')] as boolean,
|
||||
resolved_at: row[key('resolved_at')] ? new Date(row[key('resolved_at')] as string) : null,
|
||||
created_at: new Date(row[key('created_at')] as string)
|
||||
}
|
||||
}
|
||||
|
||||
private mapWebhookRow(row: Record<string, unknown>, prefix: string = ''): WebhookSubscription {
|
||||
const key = (k: string) => (prefix ? `${prefix}${k}` : k)
|
||||
return {
|
||||
id: row[key('id')] as number,
|
||||
asn: row[key('asn')] as number,
|
||||
endpoint_url: row[key('endpoint_url')] as string,
|
||||
secret_key: row[key('secret_key')] as string,
|
||||
created_at: new Date(row[key('created_at')] as string),
|
||||
last_triggered_at: row[key('last_triggered_at')] ? new Date(row[key('last_triggered_at')] as string) : null,
|
||||
failure_count: row[key('failure_count')] as number,
|
||||
active: row[key('active')] as boolean,
|
||||
max_retries: row[key('max_retries')] as number,
|
||||
timeout_ms: row[key('timeout_ms')] as number,
|
||||
metadata: row[key('metadata')] as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
private mapDeliveryRow(row: Record<string, unknown>, prefix: string = ''): WebhookDelivery {
|
||||
const key = (k: string) => (prefix ? `${prefix}${k}` : k)
|
||||
return {
|
||||
id: row[key('id')] as number,
|
||||
subscription_id: row[key('subscription_id')] as number,
|
||||
event_id: row[key('event_id')] as number,
|
||||
attempt_number: row[key('attempt_number')] as number,
|
||||
sent_at: new Date(row[key('sent_at')] as string),
|
||||
response_status: row[key('response_status')] as number | null,
|
||||
response_body: row[key('response_body')] as string | null,
|
||||
error_message: row[key('error_message')] as string | null,
|
||||
next_retry_at: row[key('next_retry_at')] ? new Date(row[key('next_retry_at')] as string) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
import type { CreateHijackEventInput } from './types'
|
||||
import type { HijackAlertsDatabaseClient } from './db-client'
|
||||
|
||||
export interface HijackDetectionResult {
|
||||
detected: boolean
|
||||
event?: CreateHijackEventInput
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface HijackClassification {
|
||||
hijack_type: 'MOAS' | 'HIJACK' | 'LEAK'
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
baseDescription: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic hijack classification based on network characteristics
|
||||
* - MOAS (Multiple Origin AS): Multiple ASNs legitimately announce same prefix
|
||||
* - HIJACK: Unauthorized ASN announcing prefix it doesn't own
|
||||
* - LEAK: ASN accidentally leaking customer or upstream prefix
|
||||
*/
|
||||
export function classifyHijack(
|
||||
expectedAsn: number,
|
||||
detectedAsns: number[],
|
||||
prefix: string
|
||||
): HijackClassification {
|
||||
if (!detectedAsns.length) {
|
||||
return {
|
||||
hijack_type: 'HIJACK',
|
||||
severity: 'HIGH',
|
||||
baseDescription: 'Prefix announced but no origin ASN detected'
|
||||
}
|
||||
}
|
||||
|
||||
const prefixParts = prefix.split('/')
|
||||
const prefixLength = prefixParts.length > 1 ? parseInt(prefixParts[1]) : 0
|
||||
|
||||
// MOAS detection: Multiple legitimate ASNs for same prefix
|
||||
if (detectedAsns.includes(expectedAsn) && detectedAsns.length > 1) {
|
||||
return {
|
||||
hijack_type: 'MOAS',
|
||||
severity: 'MEDIUM',
|
||||
baseDescription: `Multiple Origin AS detected: ${detectedAsns.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// LEAK detection: Expected ASN not in detected list, but detected ASNs are typically upstream/customer
|
||||
if (!detectedAsns.includes(expectedAsn)) {
|
||||
const expectedIsSmall = expectedAsn > 64512 // Private ASN range
|
||||
const detectedArePrivate = detectedAsns.every(asn => asn > 64512)
|
||||
|
||||
if (expectedIsSmall && detectedArePrivate) {
|
||||
return {
|
||||
hijack_type: 'LEAK',
|
||||
severity: 'MEDIUM',
|
||||
baseDescription: `Private ASN prefix leak: Expected ${expectedAsn}, detected ${detectedAsns.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// HIJACK: Completely unexpected ASN announcing prefix
|
||||
return {
|
||||
hijack_type: 'HIJACK',
|
||||
severity: prefixLength >= 24 ? 'CRITICAL' : 'HIGH', // Smaller prefixes more severe
|
||||
baseDescription: `Unauthorized ASN(s) detected: ${detectedAsns.join(', ')} announcing ${expectedAsn}'s prefix`
|
||||
}
|
||||
}
|
||||
|
||||
// Default: Hijack with severity based on prefix specificity
|
||||
return {
|
||||
hijack_type: 'HIJACK',
|
||||
severity: prefixLength >= 24 ? 'CRITICAL' : 'HIGH',
|
||||
baseDescription: `Prefix hijack detected on ${prefix}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional Ollama enrichment for CRITICAL severity hijacks
|
||||
* Falls back gracefully if Ollama is unavailable
|
||||
*/
|
||||
export async function enrichHijackDescriptionWithOllama(
|
||||
baseDescription: string,
|
||||
prefix: string,
|
||||
detectedAsns: number[]
|
||||
): Promise<string> {
|
||||
try {
|
||||
const ollamaUrl = process.env.OLLAMA_URL || 'http://localhost:11434'
|
||||
const prompt = `You are a network security analyst. Provide a 1-2 sentence technical assessment of this BGP hijack:
|
||||
- Prefix: ${prefix}
|
||||
- Detected from ASN(s): ${detectedAsns.join(', ')}
|
||||
- Initial assessment: ${baseDescription}
|
||||
|
||||
Focus on impact and recommended immediate actions.`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout
|
||||
|
||||
const response = await fetch(`${ollamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'qwen2.5:3b',
|
||||
prompt,
|
||||
stream: false,
|
||||
temperature: 0.3
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
return baseDescription // Fallback to base description
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { response?: string }
|
||||
const enrichedText = data.response?.trim()
|
||||
|
||||
// Only include AI Analysis if we got meaningful enrichment
|
||||
if (!enrichedText) {
|
||||
return baseDescription
|
||||
}
|
||||
|
||||
return `${baseDescription}\n\nAI Analysis: ${enrichedText}`
|
||||
} catch (error) {
|
||||
// Graceful fallback: Ollama unavailable or timeout
|
||||
return baseDescription
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForHijacks(asnPrefix: string, dbClient: HijackAlertsDatabaseClient): Promise<HijackDetectionResult[]> {
|
||||
const results: HijackDetectionResult[] = []
|
||||
|
||||
// Parse ASN prefixes (e.g., "13335:185.1.0.0/24")
|
||||
const parts = asnPrefix.split(':')
|
||||
if (parts.length !== 2) {
|
||||
return [{ detected: false, reason: 'Invalid ASN:prefix format' }]
|
||||
}
|
||||
|
||||
const [asnStr, prefix] = parts
|
||||
const asn = parseInt(asnStr)
|
||||
|
||||
if (isNaN(asn)) {
|
||||
return [{ detected: false, reason: 'Invalid ASN number' }]
|
||||
}
|
||||
|
||||
// Check if this hijack was recently detected (deduplicate)
|
||||
const recentEvent = await dbClient.getRecentHijackEvent(asn, prefix, 6)
|
||||
if (recentEvent) {
|
||||
return [{ detected: false, reason: 'Recently detected (within 6 hours), skipping to avoid spam' }]
|
||||
}
|
||||
|
||||
// Simulate hijack detection (in production, integrate with bgp.he.net, RIPE Stat, etc.)
|
||||
const detectionResult = simulateHijackDetection(asn, prefix)
|
||||
|
||||
if (detectionResult.detected && detectionResult.detected_asns) {
|
||||
// Deterministic classification
|
||||
const classification = classifyHijack(asn, detectionResult.detected_asns, prefix)
|
||||
|
||||
// Optional Ollama enrichment for CRITICAL severity
|
||||
let finalDescription = classification.baseDescription
|
||||
if (classification.severity === 'CRITICAL') {
|
||||
finalDescription = await enrichHijackDescriptionWithOllama(
|
||||
classification.baseDescription,
|
||||
prefix,
|
||||
detectionResult.detected_asns
|
||||
)
|
||||
}
|
||||
|
||||
results.push({
|
||||
detected: true,
|
||||
event: {
|
||||
asn,
|
||||
prefix,
|
||||
expected_asn: asn,
|
||||
detected_asns: detectionResult.detected_asns,
|
||||
hijack_type: classification.hijack_type,
|
||||
severity: classification.severity,
|
||||
description: finalDescription,
|
||||
details: { source: 'bgp_hijack_monitor', timestamp: new Date().toISOString() }
|
||||
}
|
||||
})
|
||||
} else {
|
||||
results.push({ detected: false, reason: 'No hijack detected' })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
interface DetectionSimulation {
|
||||
detected: boolean
|
||||
detected_asns?: number[]
|
||||
}
|
||||
|
||||
function simulateHijackDetection(asn: number, prefix: string): DetectionSimulation {
|
||||
// Placeholder: in production, integrate with bgp.he.net, RIPE Stat, bgproutes.io, etc.
|
||||
// For now, return false (no hijack detected)
|
||||
return { detected: false }
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
import cron from 'node-cron'
|
||||
import type { HijackAlertsDatabaseClient } from './db-client'
|
||||
import { WebhookClient } from './webhook-client'
|
||||
|
||||
export class RetryScheduler {
|
||||
private cronJob: cron.ScheduledTask | null = null
|
||||
private isRunning = false
|
||||
|
||||
constructor(private dbClient: HijackAlertsDatabaseClient, private webhookClient: WebhookClient) {}
|
||||
|
||||
start(): void {
|
||||
if (this.cronJob) return
|
||||
|
||||
this.cronJob = cron.schedule('*/5 * * * *', async () => {
|
||||
if (this.isRunning) return
|
||||
this.isRunning = true
|
||||
|
||||
try {
|
||||
await this.processFailedDeliveries()
|
||||
} catch (error) {
|
||||
console.error('[Retry Scheduler] Error processing failed deliveries:', error)
|
||||
} finally {
|
||||
this.isRunning = false
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[Retry Scheduler] Started (runs every 5 minutes)')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.cronJob) {
|
||||
this.cronJob.stop()
|
||||
this.cronJob = null
|
||||
console.log('[Retry Scheduler] Stopped')
|
||||
}
|
||||
}
|
||||
|
||||
private async processFailedDeliveries(): Promise<void> {
|
||||
const failedDeliveries = await this.dbClient.getFailedDeliveriesForRetry()
|
||||
|
||||
if (failedDeliveries.length === 0) return
|
||||
|
||||
console.log(`[Retry Scheduler] Processing ${failedDeliveries.length} failed webhook deliveries`)
|
||||
|
||||
for (const { webhook, event, delivery } of failedDeliveries) {
|
||||
const result = await this.webhookClient.sendWebhook(event, webhook.endpoint_url, webhook.secret_key, webhook.timeout_ms)
|
||||
|
||||
let newFailureCount = webhook.failure_count
|
||||
if (!result.success) {
|
||||
newFailureCount += 1
|
||||
}
|
||||
|
||||
await this.dbClient.recordWebhookDelivery(webhook.id, event.id, delivery.attempt_number + 1, result.status || null, null, result.error || null)
|
||||
|
||||
if (result.success) {
|
||||
await this.dbClient.updateWebhookLastTriggered(webhook.id, 0)
|
||||
console.log(`[Retry Scheduler] Webhook ${webhook.id} delivered successfully`)
|
||||
} else {
|
||||
if (delivery.attempt_number + 1 >= webhook.max_retries) {
|
||||
await this.dbClient.updateWebhookStatus(webhook.id, false)
|
||||
console.warn(`[Retry Scheduler] Webhook ${webhook.id} disabled after ${webhook.max_retries} failed attempts`)
|
||||
} else {
|
||||
await this.dbClient.updateWebhookLastTriggered(webhook.id, newFailureCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
export interface HijackEvent {
|
||||
id: number
|
||||
asn: number
|
||||
prefix: string
|
||||
detected_at: Date
|
||||
expected_asn: number
|
||||
detected_asns: number[]
|
||||
hijack_type: 'MOAS' | 'HIJACK' | 'LEAK'
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
description: string
|
||||
details: Record<string, unknown>
|
||||
resolved: boolean
|
||||
resolved_at: Date | null
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
export interface WebhookSubscription {
|
||||
id: number
|
||||
asn: number
|
||||
endpoint_url: string
|
||||
secret_key: string
|
||||
created_at: Date
|
||||
last_triggered_at: Date | null
|
||||
failure_count: number
|
||||
active: boolean
|
||||
max_retries: number
|
||||
timeout_ms: number
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: number
|
||||
subscription_id: number
|
||||
event_id: number
|
||||
attempt_number: number
|
||||
sent_at: Date
|
||||
response_status: number | null
|
||||
response_body: string | null
|
||||
error_message: string | null
|
||||
next_retry_at: Date | null
|
||||
}
|
||||
|
||||
export interface CreateHijackEventInput {
|
||||
asn: number
|
||||
prefix: string
|
||||
expected_asn: number
|
||||
detected_asns: number[]
|
||||
hijack_type: 'MOAS' | 'HIJACK' | 'LEAK'
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
description: string
|
||||
details: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface CreateWebhookInput {
|
||||
asn: number
|
||||
endpoint_url: string
|
||||
timeout_ms?: number
|
||||
max_retries?: number
|
||||
}
|
||||
|
||||
export interface WebhookPayload {
|
||||
event: HijackEvent
|
||||
timestamp: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface ResolveHijackInput {
|
||||
resolution_notes: string
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import crypto from 'crypto'
|
||||
import type { WebhookPayload, HijackEvent } from './types'
|
||||
|
||||
export class WebhookClient {
|
||||
async sendWebhook(event: HijackEvent, endpoint_url: string, secret_key: string, timeout_ms: number = 10000): Promise<{ success: boolean; status?: number; error?: string; response_time_ms: number }> {
|
||||
const startTime = Date.now()
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
const payload = {
|
||||
event,
|
||||
timestamp
|
||||
}
|
||||
|
||||
const signature = this.generateSignature(JSON.stringify(payload), secret_key)
|
||||
|
||||
try {
|
||||
const response = await axios.post(endpoint_url, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': signature,
|
||||
'X-Webhook-Timestamp': timestamp
|
||||
},
|
||||
timeout: timeout_ms
|
||||
})
|
||||
|
||||
return {
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
response_time_ms: Date.now() - startTime
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
return {
|
||||
success: false,
|
||||
status: axiosError.response?.status,
|
||||
error: axiosError.message,
|
||||
response_time_ms: Date.now() - startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calculateBackoffDelay(attemptNumber: number): number {
|
||||
if (attemptNumber === 0) return 0.5
|
||||
const baseDelay = 1000
|
||||
const delayMs = baseDelay * Math.pow(2, attemptNumber - 1)
|
||||
const maxDelay = 32000
|
||||
return Math.min(delayMs, maxDelay)
|
||||
}
|
||||
|
||||
private generateSignature(payload: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(payload).digest('hex')
|
||||
}
|
||||
}
|
||||
@ -1,231 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { PDFCacheManager } from '../cache-manager'
|
||||
|
||||
describe('PDFCacheManager', () => {
|
||||
let cache: PDFCacheManager
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new PDFCacheManager()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cache.destroy()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('set and get', () => {
|
||||
it('should store and retrieve a PDF buffer', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const key = 'test_key_123'
|
||||
|
||||
cache.set(key, buffer)
|
||||
const retrieved = cache.get(key)
|
||||
|
||||
expect(retrieved).not.toBeNull()
|
||||
expect(retrieved?.pdfBuffer).toEqual(buffer)
|
||||
})
|
||||
|
||||
it('should increment hits counter on get', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const key = 'test_key_123'
|
||||
|
||||
cache.set(key, buffer)
|
||||
const entry1 = cache.get(key)
|
||||
|
||||
expect(entry1?.hits).toBe(1)
|
||||
|
||||
const entry2 = cache.get(key)
|
||||
expect(entry2?.hits).toBe(2)
|
||||
})
|
||||
|
||||
it('should return null for non-existent key', () => {
|
||||
const result = cache.get('non_existent_key')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expiry and cleanup', () => {
|
||||
it('should expire entries after TTL', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const key = 'test_key_123'
|
||||
|
||||
cache.set(key, buffer)
|
||||
expect(cache.get(key)).not.toBeNull()
|
||||
|
||||
// Advance time past TTL (5 minutes = 5 * 60 * 1000 ms)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1000)
|
||||
|
||||
expect(cache.get(key)).toBeNull()
|
||||
})
|
||||
|
||||
it('should remove expired entries on get', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const key = 'test_key_123'
|
||||
|
||||
cache.set(key, buffer)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1000)
|
||||
|
||||
const stats1 = cache.getStats()
|
||||
cache.get(key) // This should trigger cleanup
|
||||
const stats2 = cache.getStats()
|
||||
|
||||
expect(stats1.size).toBe(1)
|
||||
expect(stats2.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should cleanup expired entries in background', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
|
||||
cache.set('key_1', buffer)
|
||||
cache.set('key_2', buffer)
|
||||
cache.set('key_3', buffer)
|
||||
|
||||
expect(cache.getStats().size).toBe(3)
|
||||
|
||||
// Advance time past TTL
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1000)
|
||||
// Trigger cleanup interval (60 seconds)
|
||||
vi.advanceTimersByTime(60000)
|
||||
|
||||
expect(cache.getStats().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateKey', () => {
|
||||
it('should generate consistent keys for same inputs', () => {
|
||||
const key1 = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
const key2 = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
|
||||
expect(key1).toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate different keys for different ASNs', () => {
|
||||
const key1 = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
const key2 = cache.generateKey(13336, 'report', '2026-04-29')
|
||||
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate different keys for different formats', () => {
|
||||
const key1 = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
const key2 = cache.generateKey(13335, 'executive', '2026-04-29')
|
||||
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate different keys for different dates', () => {
|
||||
const key1 = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
const key2 = cache.generateKey(13335, 'report', '2026-04-30')
|
||||
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('should generate MD5 hash format', () => {
|
||||
const key = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
expect(key).toMatch(/^[a-f0-9]{32}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return correct stats for empty cache', () => {
|
||||
const stats = cache.getStats()
|
||||
|
||||
expect(stats.size).toBe(0)
|
||||
expect(stats.entries).toBe(0)
|
||||
expect(stats.totalMemory).toBe(0)
|
||||
})
|
||||
|
||||
it('should calculate total memory correctly', () => {
|
||||
const buffer1 = Buffer.from('a'.repeat(1000))
|
||||
const buffer2 = Buffer.from('b'.repeat(2000))
|
||||
|
||||
cache.set('key_1', buffer1)
|
||||
cache.set('key_2', buffer2)
|
||||
|
||||
const stats = cache.getStats()
|
||||
|
||||
expect(stats.size).toBe(2)
|
||||
expect(stats.entries).toBe(2)
|
||||
expect(stats.totalMemory).toBe(3000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all cached entries', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
|
||||
cache.set('key_1', buffer)
|
||||
cache.set('key_2', buffer)
|
||||
|
||||
expect(cache.getStats().size).toBe(2)
|
||||
|
||||
cache.clear()
|
||||
|
||||
expect(cache.getStats().size).toBe(0)
|
||||
})
|
||||
|
||||
it('should allow setting new entries after clear', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
|
||||
cache.set('key_1', buffer)
|
||||
cache.clear()
|
||||
cache.set('key_2', buffer)
|
||||
|
||||
expect(cache.getStats().size).toBe(1)
|
||||
expect(cache.get('key_2')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hash tracking', () => {
|
||||
it('should calculate SHA256 hash for stored PDF', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const key = 'test_key_123'
|
||||
|
||||
cache.set(key, buffer)
|
||||
const entry = cache.get(key)
|
||||
|
||||
expect(entry?.hash).toBeDefined()
|
||||
expect(entry?.hash).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('should generate same hash for same buffer content', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const key1 = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
const key2 = cache.generateKey(13335, 'executive', '2026-04-29')
|
||||
|
||||
cache.set(key1, buffer)
|
||||
cache.set(key2, buffer)
|
||||
|
||||
const entry1 = cache.get(key1)
|
||||
const entry2 = cache.get(key2)
|
||||
|
||||
expect(entry1?.hash).toBe(entry2?.hash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timestamp tracking', () => {
|
||||
it('should track createdAt timestamp', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const now = Date.now()
|
||||
|
||||
cache.set('test_key', buffer)
|
||||
const entry = cache.get('test_key')
|
||||
|
||||
expect(entry?.createdAt).toBeGreaterThanOrEqual(now)
|
||||
expect(entry?.createdAt).toBeLessThanOrEqual(Date.now())
|
||||
})
|
||||
|
||||
it('should track expiresAt timestamp correctly', () => {
|
||||
const buffer = Buffer.from('test pdf content')
|
||||
const now = Date.now()
|
||||
const ttl = 5 * 60 * 1000
|
||||
|
||||
cache.set('test_key', buffer)
|
||||
const entry = cache.get('test_key')
|
||||
|
||||
expect(entry?.expiresAt).toBeGreaterThanOrEqual(now + ttl)
|
||||
expect(entry?.expiresAt).toBeLessThanOrEqual(now + ttl + 1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,298 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { PDFExportDatabaseClient } from '../db-client'
|
||||
import type { Pool, QueryResult } from 'pg'
|
||||
|
||||
describe('PDFExportDatabaseClient', () => {
|
||||
let mockPool: Partial<Pool>
|
||||
let client: PDFExportDatabaseClient
|
||||
|
||||
beforeEach(() => {
|
||||
mockPool = {
|
||||
query: vi.fn(),
|
||||
}
|
||||
|
||||
client = new PDFExportDatabaseClient(mockPool as Pool)
|
||||
})
|
||||
|
||||
describe('recordPDFGeneration', () => {
|
||||
it('should insert PDF generation record', async () => {
|
||||
const mockResult: QueryResult = {
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
resource_type: 'asn',
|
||||
resource_value: 'AS13335',
|
||||
format: 'report',
|
||||
generated_at: new Date('2026-04-29T10:00:00Z'),
|
||||
expires_at: new Date('2026-04-29T10:05:00Z'),
|
||||
file_hash: 'abc123def456',
|
||||
file_size_bytes: 1024000,
|
||||
metadata: { format: 'report', generated_at: '2026-04-29T10:00:00Z' },
|
||||
},
|
||||
],
|
||||
command: 'INSERT',
|
||||
rowCount: 1,
|
||||
oid: undefined,
|
||||
fields: [],
|
||||
}
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce(mockResult)
|
||||
|
||||
const result = await client.recordPDFGeneration(
|
||||
'AS13335',
|
||||
'report',
|
||||
'abc123def456',
|
||||
1024000,
|
||||
{ format: 'report', generated_at: '2026-04-29T10:00:00Z' }
|
||||
)
|
||||
|
||||
expect(result.id).toBe(1)
|
||||
expect(result.resource_value).toBe('AS13335')
|
||||
expect(result.format).toBe('report')
|
||||
expect(mockPool.query).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle ON CONFLICT for duplicate PDFs on same day', async () => {
|
||||
const mockResult: QueryResult = {
|
||||
rows: [
|
||||
{
|
||||
id: 2,
|
||||
resource_type: 'asn',
|
||||
resource_value: 'AS13335',
|
||||
format: 'report',
|
||||
generated_at: new Date('2026-04-29T10:05:00Z'),
|
||||
expires_at: new Date('2026-04-29T10:10:00Z'),
|
||||
file_hash: 'newHash789',
|
||||
file_size_bytes: 1050000,
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
command: 'UPDATE',
|
||||
rowCount: 1,
|
||||
oid: undefined,
|
||||
fields: [],
|
||||
}
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce(mockResult)
|
||||
|
||||
const result = await client.recordPDFGeneration(
|
||||
'AS13335',
|
||||
'report',
|
||||
'newHash789',
|
||||
1050000,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result.file_hash).toBe('newHash789')
|
||||
expect(result.file_size_bytes).toBe(1050000)
|
||||
})
|
||||
|
||||
it('should pass correct parameters to query', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [{}],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
await client.recordPDFGeneration('AS13335', 'report', 'hash123', 2048000, {
|
||||
custom: 'metadata',
|
||||
})
|
||||
|
||||
const callArgs = (mockPool.query as any).mock.calls[0]
|
||||
expect(callArgs[1]).toEqual([
|
||||
'asn',
|
||||
'AS13335',
|
||||
'report',
|
||||
'hash123',
|
||||
2048000,
|
||||
JSON.stringify({ custom: 'metadata' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPDFByHash', () => {
|
||||
it('should retrieve PDF by hash', async () => {
|
||||
const mockPdf = {
|
||||
id: 1,
|
||||
resource_type: 'asn',
|
||||
resource_value: 'AS13335',
|
||||
format: 'report',
|
||||
generated_at: new Date(),
|
||||
expires_at: new Date(Date.now() + 300000),
|
||||
file_hash: 'abc123def456',
|
||||
file_size_bytes: 1024000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [mockPdf],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const result = await client.getPDFByHash('abc123def456')
|
||||
|
||||
expect(result).toEqual(mockPdf)
|
||||
expect((mockPool.query as any).mock.calls[0][1]).toEqual(['abc123def456'])
|
||||
})
|
||||
|
||||
it('should return null if PDF not found', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
|
||||
const result = await client.getPDFByHash('nonexistent')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null if PDF expired', async () => {
|
||||
const mockExpiredPdf = {
|
||||
id: 1,
|
||||
resource_type: 'asn',
|
||||
resource_value: 'AS13335',
|
||||
format: 'report',
|
||||
generated_at: new Date(),
|
||||
expires_at: new Date(Date.now() - 1000), // Already expired
|
||||
file_hash: 'abc123def456',
|
||||
file_size_bytes: 1024000,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [mockExpiredPdf],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
// Note: The actual query filters expired PDFs, so this would return null in real usage
|
||||
const result = await client.getPDFByHash('abc123def456')
|
||||
|
||||
// In the mock, we're returning the expired PDF, but in reality
|
||||
// the WHERE clause would filter it out
|
||||
expect(result?.expires_at.getTime()).toBeLessThan(new Date().getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupExpiredPDFs', () => {
|
||||
it('should delete expired PDFs and return count', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rowCount: 5,
|
||||
})
|
||||
|
||||
const result = await client.cleanupExpiredPDFs()
|
||||
|
||||
expect(result).toBe(5)
|
||||
})
|
||||
|
||||
it('should return 0 if no expired PDFs', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rowCount: 0,
|
||||
})
|
||||
|
||||
const result = await client.cleanupExpiredPDFs()
|
||||
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('should execute correct DELETE query', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rowCount: 3,
|
||||
})
|
||||
|
||||
await client.cleanupExpiredPDFs()
|
||||
|
||||
const queryCall = (mockPool.query as any).mock.calls[0][0]
|
||||
expect(queryCall).toContain('DELETE FROM pdf_reports')
|
||||
expect(queryCall).toContain('WHERE expires_at <= NOW()')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPDFStats', () => {
|
||||
it('should return PDF statistics', async () => {
|
||||
const mockStats = {
|
||||
total_records: 25,
|
||||
total_size_bytes: 52428800,
|
||||
expired_records: 3,
|
||||
}
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [mockStats],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const result = await client.getPDFStats()
|
||||
|
||||
expect(result.total_records).toBe(25)
|
||||
expect(result.total_size_bytes).toBe(52428800)
|
||||
expect(result.expired_records).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle zero records', async () => {
|
||||
const mockStats = {
|
||||
total_records: 0,
|
||||
total_size_bytes: 0,
|
||||
expired_records: 0,
|
||||
}
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [mockStats],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const result = await client.getPDFStats()
|
||||
|
||||
expect(result.total_records).toBe(0)
|
||||
expect(result.total_size_bytes).toBe(0)
|
||||
expect(result.expired_records).toBe(0)
|
||||
})
|
||||
|
||||
it('should calculate aggregates correctly in SQL', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [{}],
|
||||
})
|
||||
|
||||
await client.getPDFStats()
|
||||
|
||||
const queryCall = (mockPool.query as any).mock.calls[0][0]
|
||||
const normalizedQuery = queryCall.replace(/\s+/g, ' ')
|
||||
expect(normalizedQuery).toContain('COUNT(*) as total_records')
|
||||
expect(normalizedQuery).toContain('total_size_bytes')
|
||||
expect(normalizedQuery).toContain(
|
||||
'COUNT(CASE WHEN expires_at <= NOW() THEN 1 END) as expired_records'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use COALESCE to handle null sums', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [{}],
|
||||
})
|
||||
|
||||
await client.getPDFStats()
|
||||
|
||||
const queryCall = (mockPool.query as any).mock.calls[0][0]
|
||||
expect(queryCall).toContain('COALESCE(SUM(file_size_bytes), 0)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('query error handling', () => {
|
||||
it('should propagate query errors', async () => {
|
||||
;(mockPool.query as any).mockRejectedValueOnce(
|
||||
new Error('Database connection failed')
|
||||
)
|
||||
|
||||
await expect(client.recordPDFGeneration('AS13335', 'report', 'hash', 1000)).rejects.toThrow(
|
||||
'Database connection failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle missing result rows', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
|
||||
const result = await client.recordPDFGeneration('AS13335', 'report', 'hash', 1000)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,372 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { PDFCacheManager } from '../cache-manager'
|
||||
import { PDFRenderer } from '../renderer'
|
||||
import { PDFExportDatabaseClient } from '../db-client'
|
||||
import { renderTemplate } from '../renderer'
|
||||
import type { PDFTemplateData } from '../types'
|
||||
import type { Pool } from 'pg'
|
||||
|
||||
// Mock Playwright for renderer tests
|
||||
vi.mock('playwright', () => {
|
||||
const mockPage = {
|
||||
setContent: vi.fn().mockResolvedValue(undefined),
|
||||
pdf: vi.fn().mockResolvedValue(Buffer.from('pdf binary content')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const mockBrowser = {
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
return {
|
||||
chromium: {
|
||||
launch: vi.fn().mockResolvedValue(mockBrowser),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PDF Export Integration', () => {
|
||||
let cache: PDFCacheManager
|
||||
let renderer: PDFRenderer
|
||||
let mockPool: Partial<Pool>
|
||||
let db: PDFExportDatabaseClient
|
||||
|
||||
const testData: PDFTemplateData = {
|
||||
asn: 13335,
|
||||
network_name: 'Cloudflare Inc.',
|
||||
generated_at: '2026-04-29T10:00:00Z',
|
||||
health_score: {
|
||||
overall: 85,
|
||||
aspa: 72,
|
||||
rpki: 90,
|
||||
bgp_stability: 88,
|
||||
peering_health: 80,
|
||||
},
|
||||
aspa: {
|
||||
adoption_status: 'in_progress',
|
||||
provider_verification: 55,
|
||||
readiness_score: 70,
|
||||
},
|
||||
peering: {
|
||||
ixp_connections: 15,
|
||||
peer_count: 45,
|
||||
open_peers: 32,
|
||||
route_exports: 1500,
|
||||
},
|
||||
threats: {
|
||||
recent_hijacks: 0,
|
||||
anomalies_detected: 1,
|
||||
rpki_invalids: 2,
|
||||
moas_events: 0,
|
||||
},
|
||||
recommendations: [
|
||||
'Maintain RPKI ROV implementation',
|
||||
'Continue ASPA provider adoption',
|
||||
'Monitor anomalies monthly',
|
||||
],
|
||||
data_sources: [
|
||||
'RIPE RIS Route Collectors',
|
||||
'RouteViews BGP Archive',
|
||||
'RPKI Repository Objects',
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new PDFCacheManager()
|
||||
renderer = new PDFRenderer()
|
||||
mockPool = {
|
||||
query: vi.fn(),
|
||||
}
|
||||
db = new PDFExportDatabaseClient(mockPool as Pool)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cache.destroy()
|
||||
await renderer.close()
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Full PDF generation workflow', () => {
|
||||
it('should render template, cache result, and record in database', async () => {
|
||||
const dateStr = '2026-04-29'
|
||||
const format = 'report'
|
||||
const cacheKey = cache.generateKey(13335, format, dateStr)
|
||||
|
||||
// Step 1: Render template
|
||||
const htmlTemplate = `
|
||||
<html>
|
||||
<head><title>{{network_name}} Report</title></head>
|
||||
<body>
|
||||
<h1>{{network_name}}</h1>
|
||||
<p>ASN: {{asn}}</p>
|
||||
<p>Health Score: {{health_score.overall}}</p>
|
||||
<ul>
|
||||
{{#each recommendations}}<li>{{this}}</li>{{/each}}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const renderedHtml = renderTemplate(htmlTemplate, testData)
|
||||
|
||||
expect(renderedHtml).toContain('Cloudflare Inc.')
|
||||
expect(renderedHtml).toContain('ASN: 13335')
|
||||
expect(renderedHtml).toContain('Health Score: 85')
|
||||
expect(renderedHtml).toContain('Maintain RPKI ROV implementation')
|
||||
|
||||
// Step 2: Render PDF
|
||||
const pdfBuffer = await renderer.renderPDF(renderedHtml)
|
||||
|
||||
expect(pdfBuffer).toBeInstanceOf(Buffer)
|
||||
expect(pdfBuffer.length).toBeGreaterThan(0)
|
||||
|
||||
// Step 3: Cache the PDF
|
||||
cache.set(cacheKey, pdfBuffer)
|
||||
|
||||
const cachedEntry = cache.get(cacheKey)
|
||||
expect(cachedEntry).not.toBeNull()
|
||||
expect(cachedEntry?.pdfBuffer).toEqual(pdfBuffer)
|
||||
|
||||
// Step 4: Record in database
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
resource_type: 'asn',
|
||||
resource_value: 'AS13335',
|
||||
format: 'report',
|
||||
generated_at: new Date(),
|
||||
expires_at: new Date(Date.now() + 5 * 60 * 1000),
|
||||
file_hash: cachedEntry!.hash,
|
||||
file_size_bytes: pdfBuffer.length,
|
||||
metadata: { format },
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const dbRecord = await db.recordPDFGeneration(
|
||||
'AS13335',
|
||||
format,
|
||||
cachedEntry!.hash,
|
||||
pdfBuffer.length,
|
||||
{ format }
|
||||
)
|
||||
|
||||
expect(dbRecord.id).toBe(1)
|
||||
expect(dbRecord.file_hash).toBe(cachedEntry!.hash)
|
||||
expect(dbRecord.file_size_bytes).toBe(pdfBuffer.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache hit optimization', () => {
|
||||
it('should return cached PDF without re-rendering', async () => {
|
||||
const dateStr = '2026-04-29'
|
||||
const format = 'report'
|
||||
const cacheKey = cache.generateKey(13335, format, dateStr)
|
||||
const pdfBuffer = Buffer.from('cached pdf content')
|
||||
|
||||
// Store PDF in cache
|
||||
cache.set(cacheKey, pdfBuffer)
|
||||
const cachedEntry1 = cache.get(cacheKey)
|
||||
|
||||
// Request same PDF again (cache hit)
|
||||
const cachedEntry2 = cache.get(cacheKey)
|
||||
|
||||
expect(cachedEntry1).not.toBeNull()
|
||||
expect(cachedEntry2).not.toBeNull()
|
||||
expect(cachedEntry2!.pdfBuffer).toEqual(pdfBuffer)
|
||||
expect(cachedEntry2!.hits).toBe(2)
|
||||
})
|
||||
|
||||
it('should expire cached PDF after TTL', async () => {
|
||||
const dateStr = '2026-04-29'
|
||||
const format = 'report'
|
||||
const cacheKey = cache.generateKey(13335, format, dateStr)
|
||||
const pdfBuffer = Buffer.from('cached pdf content')
|
||||
|
||||
cache.set(cacheKey, pdfBuffer)
|
||||
expect(cache.get(cacheKey)).not.toBeNull()
|
||||
|
||||
// Advance time past TTL (5 minutes)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1000)
|
||||
|
||||
expect(cache.get(cacheKey)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple formats on same day', () => {
|
||||
it('should handle three different formats for same ASN/date', async () => {
|
||||
const dateStr = '2026-04-29'
|
||||
const asn = 13335
|
||||
const formats = ['report', 'executive', 'technical']
|
||||
|
||||
const pdfs: Map<string, Buffer> = new Map()
|
||||
|
||||
// Generate and cache PDFs for all three formats
|
||||
for (const format of formats) {
|
||||
const cacheKey = cache.generateKey(asn, format, dateStr)
|
||||
const pdfBuffer = Buffer.from(`pdf content for ${format}`)
|
||||
|
||||
cache.set(cacheKey, pdfBuffer)
|
||||
pdfs.set(format, pdfBuffer)
|
||||
}
|
||||
|
||||
// Verify all are cached
|
||||
for (const format of formats) {
|
||||
const cacheKey = cache.generateKey(asn, format, dateStr)
|
||||
const cached = cache.get(cacheKey)
|
||||
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached?.pdfBuffer).toEqual(pdfs.get(format))
|
||||
}
|
||||
|
||||
// Verify cache stats
|
||||
const stats = cache.getStats()
|
||||
expect(stats.size).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Large PDF handling', () => {
|
||||
it('should track memory usage for large PDFs', async () => {
|
||||
const largeBuffer = Buffer.from('x'.repeat(10 * 1024 * 1024)) // 10MB
|
||||
const smallBuffer = Buffer.from('y'.repeat(1 * 1024 * 1024)) // 1MB
|
||||
|
||||
cache.set('large', largeBuffer)
|
||||
cache.set('small', smallBuffer)
|
||||
|
||||
const stats = cache.getStats()
|
||||
|
||||
expect(stats.totalMemory).toBe(11 * 1024 * 1024)
|
||||
expect(stats.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle PDF size tracking in database', async () => {
|
||||
const largeBuffer = Buffer.from('x'.repeat(50 * 1024 * 1024)) // 50MB
|
||||
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
resource_type: 'asn',
|
||||
resource_value: 'AS13335',
|
||||
format: 'report',
|
||||
generated_at: new Date(),
|
||||
expires_at: new Date(),
|
||||
file_hash: 'largehash',
|
||||
file_size_bytes: largeBuffer.length,
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const record = await db.recordPDFGeneration(
|
||||
'AS13335',
|
||||
'report',
|
||||
'largehash',
|
||||
largeBuffer.length,
|
||||
{}
|
||||
)
|
||||
|
||||
expect(record.file_size_bytes).toBe(50 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deduplication by hash', () => {
|
||||
it('should detect duplicate PDFs by hash', async () => {
|
||||
const buffer = Buffer.from('same content')
|
||||
const hash1 = require('crypto').createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
// First PDF recorded
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
resource_value: 'AS13335',
|
||||
file_hash: hash1,
|
||||
file_size_bytes: buffer.length,
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const record1 = await db.recordPDFGeneration('AS13335', 'report', hash1, buffer.length)
|
||||
|
||||
// Try to get same PDF by hash
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
resource_value: 'AS13335',
|
||||
file_hash: hash1,
|
||||
file_size_bytes: buffer.length,
|
||||
expires_at: new Date(Date.now() + 5 * 60 * 1000),
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const record2 = await db.getPDFByHash(hash1)
|
||||
|
||||
expect(record1.file_hash).toBe(record2?.file_hash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Database cleanup', () => {
|
||||
it('should cleanup expired PDF records', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rowCount: 5,
|
||||
})
|
||||
|
||||
const deleted = await db.cleanupExpiredPDFs()
|
||||
|
||||
expect(deleted).toBe(5)
|
||||
})
|
||||
|
||||
it('should get statistics before cleanup', async () => {
|
||||
;(mockPool.query as any).mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
total_records: 50,
|
||||
total_size_bytes: 100000000,
|
||||
expired_records: 5,
|
||||
},
|
||||
],
|
||||
rowCount: 1,
|
||||
})
|
||||
|
||||
const stats = await db.getPDFStats()
|
||||
|
||||
expect(stats.total_records).toBe(50)
|
||||
expect(stats.expired_records).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error recovery', () => {
|
||||
it('should handle rendering failures gracefully', async () => {
|
||||
// Rendering can fail if HTML is malformed
|
||||
const malformedTemplate = 'No template variables here'
|
||||
const result = renderTemplate(malformedTemplate, testData)
|
||||
|
||||
expect(result).toBe(malformedTemplate)
|
||||
})
|
||||
|
||||
it('should recover from cache misses', async () => {
|
||||
const cacheKey = cache.generateKey(13335, 'report', '2026-04-29')
|
||||
|
||||
// Cache miss
|
||||
const cached = cache.get(cacheKey)
|
||||
expect(cached).toBeNull()
|
||||
|
||||
// Should be able to generate new PDF
|
||||
const htmlTemplate = '<html><body>{{network_name}}</body></html>'
|
||||
const rendered = renderTemplate(htmlTemplate, testData)
|
||||
|
||||
expect(rendered).toContain('Cloudflare Inc.')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,387 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { PDFRenderer, renderTemplate } from '../renderer'
|
||||
import type { PDFTemplateData } from '../types'
|
||||
|
||||
// Mock Playwright
|
||||
vi.mock('playwright', () => {
|
||||
const mockPage = {
|
||||
setContent: vi.fn().mockResolvedValue(undefined),
|
||||
pdf: vi.fn().mockResolvedValue(Buffer.from('mock pdf content')),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const mockBrowser = {
|
||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
return {
|
||||
chromium: {
|
||||
launch: vi.fn().mockResolvedValue(mockBrowser),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PDFRenderer', () => {
|
||||
let renderer: PDFRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
renderer = new PDFRenderer()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await renderer.close()
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should launch chromium browser', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
|
||||
await renderer.initialize()
|
||||
|
||||
expect(chromium.launch).toHaveBeenCalledWith({ headless: true })
|
||||
})
|
||||
|
||||
it('should not relaunch browser on second initialize', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
|
||||
await renderer.initialize()
|
||||
vi.clearAllMocks()
|
||||
await renderer.initialize()
|
||||
|
||||
expect(chromium.launch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPDF', () => {
|
||||
it('should render HTML to PDF buffer', async () => {
|
||||
const html = '<html><body>Test PDF</body></html>'
|
||||
|
||||
const result = await renderer.renderPDF(html)
|
||||
|
||||
expect(result).toBeInstanceOf(Buffer)
|
||||
expect(result.toString()).toContain('mock pdf content')
|
||||
})
|
||||
|
||||
it('should set page content with correct options', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
const html = '<html><body>Test</body></html>'
|
||||
|
||||
await renderer.renderPDF(html)
|
||||
|
||||
const mockBrowser = await (chromium.launch as any)()
|
||||
const mockPage = await mockBrowser.newPage()
|
||||
|
||||
expect(mockPage.setContent).toHaveBeenCalledWith(html, { waitUntil: 'networkidle' })
|
||||
})
|
||||
|
||||
it('should generate PDF with A4 format', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
const html = '<html><body>Test</body></html>'
|
||||
|
||||
await renderer.renderPDF(html)
|
||||
|
||||
const mockBrowser = await (chromium.launch as any)()
|
||||
const mockPage = await mockBrowser.newPage()
|
||||
|
||||
expect(mockPage.pdf).toHaveBeenCalledWith({
|
||||
format: 'A4',
|
||||
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
||||
timeout: 30000,
|
||||
})
|
||||
})
|
||||
|
||||
it('should respect custom timeout', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
const html = '<html><body>Test</body></html>'
|
||||
const customTimeout = 60000
|
||||
|
||||
await renderer.renderPDF(html, customTimeout)
|
||||
|
||||
const mockBrowser = await (chromium.launch as any)()
|
||||
const mockPage = await mockBrowser.newPage()
|
||||
|
||||
expect(mockPage.pdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: customTimeout,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should close page after rendering', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
const html = '<html><body>Test</body></html>'
|
||||
|
||||
await renderer.renderPDF(html)
|
||||
|
||||
const mockBrowser = await (chromium.launch as any)()
|
||||
const mockPage = await mockBrowser.newPage()
|
||||
|
||||
expect(mockPage.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rendering errors', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
const html = '<html><body>Test</body></html>'
|
||||
|
||||
const mockBrowser = await (chromium.launch as any)()
|
||||
const mockPage = await mockBrowser.newPage()
|
||||
;(mockPage.pdf as any).mockRejectedValueOnce(new Error('PDF generation failed'))
|
||||
|
||||
await expect(renderer.renderPDF(html)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser health check', () => {
|
||||
it('should initialize browser if not initialized', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
|
||||
const html = '<html><body>Test</body></html>'
|
||||
await renderer.renderPDF(html)
|
||||
|
||||
expect(chromium.launch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should restart browser if lifetime exceeded', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
await renderer.initialize()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Advance time beyond MAX_BROWSER_LIFETIME (3600000ms)
|
||||
vi.advanceTimersByTime(3600001)
|
||||
|
||||
const html = '<html><body>Test</body></html>'
|
||||
await renderer.renderPDF(html)
|
||||
|
||||
// Should have launched a new browser
|
||||
expect(chromium.launch).toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('close', () => {
|
||||
it('should close browser', async () => {
|
||||
const { chromium } = await import('playwright')
|
||||
|
||||
await renderer.initialize()
|
||||
await renderer.close()
|
||||
|
||||
const mockBrowser = await (chromium.launch as any)()
|
||||
expect(mockBrowser.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set browser to null after closing', async () => {
|
||||
await renderer.initialize()
|
||||
await renderer.close()
|
||||
|
||||
// Verify browser is null by checking that next initialize calls launch
|
||||
const { chromium } = await import('playwright')
|
||||
vi.clearAllMocks()
|
||||
|
||||
await renderer.initialize()
|
||||
expect(chromium.launch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
const testData: PDFTemplateData = {
|
||||
asn: 13335,
|
||||
network_name: 'Cloudflare Network',
|
||||
generated_at: '2026-04-29T10:00:00Z',
|
||||
health_score: {
|
||||
overall: 78,
|
||||
aspa: 65,
|
||||
rpki: 85,
|
||||
bgp_stability: 80,
|
||||
peering_health: 75,
|
||||
},
|
||||
aspa: {
|
||||
adoption_status: 'in_progress',
|
||||
provider_verification: 45,
|
||||
readiness_score: 60,
|
||||
},
|
||||
peering: {
|
||||
ixp_connections: 12,
|
||||
peer_count: 42,
|
||||
open_peers: 28,
|
||||
route_exports: 1250,
|
||||
},
|
||||
threats: {
|
||||
recent_hijacks: 0,
|
||||
anomalies_detected: 2,
|
||||
rpki_invalids: 3,
|
||||
moas_events: 1,
|
||||
},
|
||||
recommendations: [
|
||||
'Implement RPKI ROV',
|
||||
'Increase ASPA adoption',
|
||||
'Monitor anomalies',
|
||||
],
|
||||
data_sources: [
|
||||
'RIPE RIS Route Collectors',
|
||||
'RouteViews BGP Archive',
|
||||
],
|
||||
}
|
||||
|
||||
describe('simple variable replacement', () => {
|
||||
it('should replace simple variables', () => {
|
||||
const template = 'ASN: {{asn}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('ASN: 13335')
|
||||
})
|
||||
|
||||
it('should replace multiple variables', () => {
|
||||
const template = 'Network: {{network_name}} (ASN {{asn}})'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('Network: Cloudflare Network (ASN 13335)')
|
||||
})
|
||||
|
||||
it('should handle string conversion for non-string types', () => {
|
||||
const template = 'Score: {{health_score.overall}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('Score: 78')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested object replacement', () => {
|
||||
it('should replace nested variables', () => {
|
||||
const template = 'Health Score: {{health_score.overall}}/100'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('Health Score: 78/100')
|
||||
})
|
||||
|
||||
it('should replace multiple nested variables', () => {
|
||||
const template = 'ASPA: {{aspa.adoption_status}}, Readiness: {{aspa.readiness_score}}%'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('ASPA: in_progress, Readiness: 60%')
|
||||
})
|
||||
|
||||
it('should handle multiple different nested objects', () => {
|
||||
const template = 'Peers: {{peering.peer_count}}, Threats: {{threats.anomalies_detected}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('Peers: 42, Threats: 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('array iteration with #each', () => {
|
||||
it('should render array items', () => {
|
||||
const template =
|
||||
'Recommendations:\n{{#each recommendations}}<li>{{this}}</li>\n{{/each}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toContain('<li>Implement RPKI ROV</li>')
|
||||
expect(result).toContain('<li>Increase ASPA adoption</li>')
|
||||
expect(result).toContain('<li>Monitor anomalies</li>')
|
||||
})
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const template = 'Sources: {{#each data_sources}}<li>{{this}}</li>{{/each}}'
|
||||
const dataWithEmpty = { ...testData, data_sources: [] }
|
||||
|
||||
const result = renderTemplate(template, dataWithEmpty)
|
||||
|
||||
expect(result).toBe('Sources: ')
|
||||
})
|
||||
|
||||
it('should handle nested HTML in array iteration', () => {
|
||||
const template =
|
||||
'{{#each recommendations}}<div class="rec">{{this}}</div>{{/each}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toContain('<div class="rec">Implement RPKI ROV</div>')
|
||||
})
|
||||
|
||||
it('should replace placeholders in array items', () => {
|
||||
const template = '{{#each data_sources}}<source>{{this}}</source>{{/each}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toContain('<source>RIPE RIS Route Collectors</source>')
|
||||
expect(result).toContain('<source>RouteViews BGP Archive</source>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex template scenarios', () => {
|
||||
it('should handle full template with mixed replacements', () => {
|
||||
const template = `
|
||||
<h1>{{network_name}}</h1>
|
||||
<p>ASN: {{asn}}</p>
|
||||
<p>Health Score: {{health_score.overall}}</p>
|
||||
<ul>
|
||||
{{#each recommendations}}<li>{{this}}</li>{{/each}}
|
||||
</ul>
|
||||
`
|
||||
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toContain('Cloudflare Network')
|
||||
expect(result).toContain('ASN: 13335')
|
||||
expect(result).toContain('Health Score: 78')
|
||||
expect(result).toContain('<li>Implement RPKI ROV</li>')
|
||||
})
|
||||
|
||||
it('should handle case-sensitive variable names', () => {
|
||||
const template = '{{asn}} {{ASN}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toContain('13335')
|
||||
expect(result).toContain('{{ASN}}') // Not replaced since case doesn't match
|
||||
})
|
||||
|
||||
it('should preserve formatting around variables', () => {
|
||||
const template = 'The score is {{health_score.overall}} out of 100'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe('The score is 78 out of 100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling and edge cases', () => {
|
||||
it('should handle template with no variables', () => {
|
||||
const template = '<html><body>Static content</body></html>'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
expect(result).toBe(template)
|
||||
})
|
||||
|
||||
it('should not fail on undefined nested properties', () => {
|
||||
const template = '{{nonexistent.property}}'
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
// Should not throw, just leave as-is or convert undefined to string
|
||||
expect(typeof result).toBe('string')
|
||||
})
|
||||
|
||||
it('should handle whitespace in variable names', () => {
|
||||
const template = 'Value: {{ asn }}' // Space around variable name
|
||||
const result = renderTemplate(template, testData)
|
||||
|
||||
// Should not match due to spaces
|
||||
expect(result).toContain('{{ asn }}')
|
||||
})
|
||||
|
||||
it('should handle special regex characters in values', () => {
|
||||
const dataWithSpecialChars = {
|
||||
...testData,
|
||||
network_name: 'Test [Network] (Special)',
|
||||
}
|
||||
|
||||
const template = 'Network: {{network_name}}'
|
||||
const result = renderTemplate(template, dataWithSpecialChars)
|
||||
|
||||
expect(result).toContain('Test [Network] (Special)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,104 +0,0 @@
|
||||
import crypto from 'crypto'
|
||||
import type { PDFReport } from './types'
|
||||
|
||||
interface CacheEntry {
|
||||
pdfBuffer: Buffer
|
||||
hash: string
|
||||
expiresAt: number
|
||||
createdAt: number
|
||||
hits: number
|
||||
}
|
||||
|
||||
export class PDFCacheManager {
|
||||
private cache: Map<string, CacheEntry> = new Map()
|
||||
private readonly TTL_MS = 5 * 60 * 1000
|
||||
private cleanupInterval: NodeJS.Timer | null = null
|
||||
|
||||
constructor() {
|
||||
this.startCleanup()
|
||||
}
|
||||
|
||||
private startCleanup(): void {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
let removed = 0
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.cache.delete(key)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`[PDFCache] Removed ${removed} expired entries`)
|
||||
}
|
||||
}, 60000)
|
||||
|
||||
if (this.cleanupInterval.unref) {
|
||||
this.cleanupInterval.unref()
|
||||
}
|
||||
}
|
||||
|
||||
get(cacheKey: string): CacheEntry | null {
|
||||
const entry = this.cache.get(cacheKey)
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
|
||||
entry.hits++
|
||||
return entry
|
||||
}
|
||||
|
||||
set(cacheKey: string, pdfBuffer: Buffer): void {
|
||||
const hash = crypto.createHash('sha256').update(pdfBuffer).digest('hex')
|
||||
const now = Date.now()
|
||||
|
||||
this.cache.set(cacheKey, {
|
||||
pdfBuffer,
|
||||
hash,
|
||||
expiresAt: now + this.TTL_MS,
|
||||
createdAt: now,
|
||||
hits: 0,
|
||||
})
|
||||
|
||||
console.log(`[PDFCache] Cached PDF: ${cacheKey} (${pdfBuffer.length} bytes)`)
|
||||
}
|
||||
|
||||
generateKey(asn: number, format: string, date: string): string {
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(`${asn}:${format}:${date}`)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
getStats(): { size: number; entries: number; totalMemory: number } {
|
||||
let totalMemory = 0
|
||||
|
||||
for (const entry of this.cache.values()) {
|
||||
totalMemory += entry.pdfBuffer.length
|
||||
}
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
entries: this.cache.size,
|
||||
totalMemory,
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
console.log('[PDFCache] Cache cleared')
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
}
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
import type { Pool, QueryResult } from 'pg'
|
||||
import type { PDFReport } from './types'
|
||||
|
||||
export class PDFExportDatabaseClient {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async recordPDFGeneration(
|
||||
resource_value: string,
|
||||
format: string,
|
||||
file_hash: string,
|
||||
file_size_bytes: number,
|
||||
metadata: Record<string, unknown> = {}
|
||||
): Promise<PDFReport> {
|
||||
const query = `
|
||||
INSERT INTO pdf_reports (
|
||||
resource_type,
|
||||
resource_value,
|
||||
format,
|
||||
generated_at,
|
||||
expires_at,
|
||||
file_hash,
|
||||
file_size_bytes,
|
||||
metadata
|
||||
) VALUES ($1, $2, $3, NOW(), NOW() + INTERVAL '5 minutes', $4, $5, $6)
|
||||
ON CONFLICT (resource_value, format, DATE(generated_at))
|
||||
DO UPDATE SET
|
||||
file_hash = EXCLUDED.file_hash,
|
||||
file_size_bytes = EXCLUDED.file_size_bytes,
|
||||
expires_at = NOW() + INTERVAL '5 minutes'
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
const result: QueryResult<PDFReport> = await this.pool.query(query, [
|
||||
'asn',
|
||||
resource_value,
|
||||
format,
|
||||
file_hash,
|
||||
file_size_bytes,
|
||||
JSON.stringify(metadata),
|
||||
])
|
||||
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
async getPDFByHash(file_hash: string): Promise<PDFReport | null> {
|
||||
const query = `
|
||||
SELECT * FROM pdf_reports
|
||||
WHERE file_hash = $1 AND expires_at > NOW()
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
const result: QueryResult<PDFReport> = await this.pool.query(query, [
|
||||
file_hash,
|
||||
])
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async cleanupExpiredPDFs(): Promise<number> {
|
||||
const query = `
|
||||
DELETE FROM pdf_reports
|
||||
WHERE expires_at <= NOW()
|
||||
`
|
||||
|
||||
const result: QueryResult = await this.pool.query(query)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
async getPDFStats(): Promise<{
|
||||
total_records: number
|
||||
total_size_bytes: number
|
||||
expired_records: number
|
||||
}> {
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(*) as total_records,
|
||||
COALESCE(SUM(file_size_bytes), 0) as total_size_bytes,
|
||||
COUNT(CASE WHEN expires_at <= NOW() THEN 1 END) as expired_records
|
||||
FROM pdf_reports
|
||||
`
|
||||
|
||||
const result: QueryResult<{
|
||||
total_records: number
|
||||
total_size_bytes: number
|
||||
expired_records: number
|
||||
}> = await this.pool.query(query)
|
||||
|
||||
return result.rows[0]
|
||||
}
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
import { chromium, Browser, Page } from 'playwright'
|
||||
import type { PDFTemplateData } from './types'
|
||||
|
||||
export class PDFRenderer {
|
||||
private browser: Browser | null = null
|
||||
private browserStartTime: number = 0
|
||||
private readonly MAX_BROWSER_LIFETIME = 3600000
|
||||
private readonly MAX_BROWSER_MEMORY = 536870912
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.browser) {
|
||||
this.browser = await chromium.launch({ headless: true })
|
||||
this.browserStartTime = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async renderPDF(html: string, timeout_ms: number = 30000): Promise<Buffer> {
|
||||
await this.initialize()
|
||||
|
||||
if (!this.browser) {
|
||||
throw new Error('Browser not initialized')
|
||||
}
|
||||
|
||||
const page = await this.browser.newPage()
|
||||
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: 'networkidle' })
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
||||
timeout: timeout_ms,
|
||||
})
|
||||
|
||||
return pdfBuffer
|
||||
} finally {
|
||||
await page.close()
|
||||
await this.checkBrowserHealth()
|
||||
}
|
||||
}
|
||||
|
||||
private async checkBrowserHealth(): Promise<void> {
|
||||
if (!this.browser) return
|
||||
|
||||
const now = Date.now()
|
||||
const lifetime = now - this.browserStartTime
|
||||
|
||||
if (lifetime > this.MAX_BROWSER_LIFETIME) {
|
||||
console.log('[PDFRenderer] Browser lifetime exceeded, restarting...')
|
||||
await this.restartBrowser()
|
||||
}
|
||||
}
|
||||
|
||||
private async restartBrowser(): Promise<void> {
|
||||
if (this.browser) {
|
||||
await this.browser.close()
|
||||
}
|
||||
this.browser = null
|
||||
await this.initialize()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.browser) {
|
||||
await this.browser.close()
|
||||
this.browser = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTemplate(html: string, data: PDFTemplateData): string {
|
||||
let rendered = html
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const itemsHtml = value.map(item => {
|
||||
const itemHtml = html.match(new RegExp(`{{#each ${key}}}(.*?){{/each}}`, 's'))
|
||||
if (itemHtml && itemHtml[1]) {
|
||||
return itemHtml[1].replace('{{this}}', String(item))
|
||||
}
|
||||
return ''
|
||||
}).join('')
|
||||
|
||||
rendered = rendered.replace(
|
||||
new RegExp(`{{#each ${key}}}.*?{{/each}}`, 's'),
|
||||
itemsHtml
|
||||
)
|
||||
} else if (typeof value === 'object') {
|
||||
Object.entries(value).forEach(([nested, nestedValue]) => {
|
||||
rendered = rendered.replace(
|
||||
new RegExp(`{{${key}\\.${nested}}}`, 'g'),
|
||||
String(nestedValue)
|
||||
)
|
||||
})
|
||||
} else {
|
||||
rendered = rendered.replace(new RegExp(`{{${key}}}`, 'g'), String(value))
|
||||
}
|
||||
})
|
||||
|
||||
return rendered
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex Executive Summary - AS{{asn}}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
background: white;
|
||||
}
|
||||
.page { page-break-after: always; padding: 40px; min-height: 100vh; }
|
||||
.page:last-child { page-break-after: avoid; }
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #1e40af;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #1e40af;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
.title-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.title-page h1 { font-size: 3.5em; margin-bottom: 0.2em; }
|
||||
.title-page .subtitle { font-size: 1.5em; color: #6b7280; margin-bottom: 2em; }
|
||||
.title-page .date { color: #9ca3af; font-size: 1em; }
|
||||
.score-card {
|
||||
background: #f3f4f6;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.score-card .label { font-size: 0.9em; color: #6b7280; margin-bottom: 5px; }
|
||||
.score-card .value { font-size: 2.5em; font-weight: bold; color: #1e40af; }
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.summary-item {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.summary-item .label { font-size: 0.85em; color: #6b7280; }
|
||||
.summary-item .value { font-size: 1.5em; font-weight: bold; color: #1e40af; margin-top: 5px; }
|
||||
.highlight {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
ul { margin-left: 25px; margin-top: 10px; }
|
||||
li { margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page title-page">
|
||||
<h1>PeerCortex Executive Summary</h1>
|
||||
<div class="subtitle">Autonomous System AS{{asn}}</div>
|
||||
<div class="subtitle">{{networkName}}</div>
|
||||
<div class="date">Generated: {{generatedAt}}</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Network Health Overview</h2>
|
||||
<div class="score-card">
|
||||
<div class="label">Overall Health Score</div>
|
||||
<div class="value">{{healthScore.overall}}/100</div>
|
||||
</div>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<div class="label">ASPA Adoption</div>
|
||||
<div class="value">{{healthScore.aspa}}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">RPKI Compliance</div>
|
||||
<div class="value">{{healthScore.rpki}}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">BGP Stability</div>
|
||||
<div class="value">{{healthScore.bgp_stability}}</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="label">Peering Health</div>
|
||||
<div class="value">{{healthScore.peering_health}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Key Findings</h2>
|
||||
<h3>Security Status</h3>
|
||||
<ul>
|
||||
<li>Recent hijacks detected: {{threats.recent_hijacks}}</li>
|
||||
<li>Network anomalies: {{threats.anomalies_detected}}</li>
|
||||
<li>RPKI invalid routes: {{threats.rpki_invalids}}</li>
|
||||
<li>MOAS events: {{threats.moas_events}}</li>
|
||||
</ul>
|
||||
|
||||
<h3>Network Infrastructure</h3>
|
||||
<ul>
|
||||
<li>IXP connections: {{peering.ixp_connections}}</li>
|
||||
<li>Peer count: {{peering.peer_count}}</li>
|
||||
<li>Route exports: {{peering.route_exports}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Strategic Recommendations</h2>
|
||||
{{#each recommendations}}
|
||||
<div class="highlight">
|
||||
<strong>{{this}}</strong>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Next Steps</h2>
|
||||
<p>Recommended actions for leadership:</p>
|
||||
<ul>
|
||||
<li>Review and address identified security threats</li>
|
||||
<li>Prioritize ASPA adoption for enhanced network security</li>
|
||||
<li>Evaluate peering relationships for optimization opportunities</li>
|
||||
<li>Schedule follow-up analysis in 90 days</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,267 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex Report - AS{{asn}}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
background: white;
|
||||
}
|
||||
.page {
|
||||
page-break-after: always;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.page:last-child {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #1e40af;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #1e40af;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.3em;
|
||||
margin-top: 0.8em;
|
||||
margin-bottom: 0.4em;
|
||||
color: #374151;
|
||||
}
|
||||
.title-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.title-page h1 {
|
||||
font-size: 3.5em;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
.title-page .subtitle {
|
||||
font-size: 1.5em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.title-page .date {
|
||||
color: #9ca3af;
|
||||
font-size: 1em;
|
||||
}
|
||||
.score-card {
|
||||
background: #f3f4f6;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.score-card .label {
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.score-card .value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
}
|
||||
.score-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.metric {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.metric .name {
|
||||
font-weight: bold;
|
||||
color: #374151;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.metric .value {
|
||||
font-size: 1.5em;
|
||||
color: #1e40af;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.recommendation {
|
||||
background: #fffbeb;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.recommendation .title {
|
||||
font-weight: bold;
|
||||
color: #d97706;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 0.85em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f3f4f6;
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page title-page">
|
||||
<h1>PeerCortex Analysis Report</h1>
|
||||
<div class="subtitle">Autonomous System AS{{asn}}</div>
|
||||
<div class="subtitle">{{networkName}}</div>
|
||||
<div class="date">Generated: {{generatedAt}}</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Executive Summary</h2>
|
||||
<div class="score-card">
|
||||
<div class="label">Overall Health Score</div>
|
||||
<div class="value">{{healthScore.overall}}/100</div>
|
||||
</div>
|
||||
<p style="margin: 20px 0;">This comprehensive report evaluates the network health and security posture of AS{{asn}} ({{networkName}}). The analysis covers ASPA adoption, RPKI compliance, BGP stability, peering relationships, and identified security threats.</p>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Health Metrics</h2>
|
||||
<div class="score-grid">
|
||||
<div class="metric">
|
||||
<div class="name">ASPA Adoption</div>
|
||||
<div class="value">{{healthScore.aspa}}/100</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="name">RPKI Compliance</div>
|
||||
<div class="value">{{healthScore.rpki}}/100</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="name">BGP Stability</div>
|
||||
<div class="value">{{healthScore.bgp_stability}}/100</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="name">Peering Health</div>
|
||||
<div class="value">{{healthScore.peering_health}}/100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>ASPA Status</h2>
|
||||
<h3>Adoption</h3>
|
||||
<p>Current Status: <strong>{{aspa.adoption_status}}</strong></p>
|
||||
<h3>Provider Verification</h3>
|
||||
<p>Provider verification readiness: {{aspa.provider_verification}}%</p>
|
||||
<h3>Readiness Score</h3>
|
||||
<p>Overall ASPA implementation readiness: {{aspa.readiness_score}}/100</p>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Peering Analysis</h2>
|
||||
<h3>Network Connections</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IXP Connections</td>
|
||||
<td>{{peering.ixp_connections}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Peer Count</td>
|
||||
<td>{{peering.peer_count}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open Peers</td>
|
||||
<td>{{peering.open_peers}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Route Exports</td>
|
||||
<td>{{peering.route_exports}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Security Threats</h2>
|
||||
<div class="score-grid">
|
||||
<div class="metric">
|
||||
<div class="name">Recent Hijacks</div>
|
||||
<div class="value">{{threats.recent_hijacks}}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="name">Anomalies Detected</div>
|
||||
<div class="value">{{threats.anomalies_detected}}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="name">RPKI Invalids</div>
|
||||
<div class="value">{{threats.rpki_invalids}}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="name">MOAS Events</div>
|
||||
<div class="value">{{threats.moas_events}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Recommendations</h2>
|
||||
{{#each recommendations}}
|
||||
<div class="recommendation">
|
||||
<div class="title">{{this}}</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>Data Provenance</h2>
|
||||
<p>This report was generated using the following data sources:</p>
|
||||
<ul style="margin-left: 20px; margin-top: 15px;">
|
||||
{{#each dataSources}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class="footer">
|
||||
<p>Report generated by PeerCortex on {{generatedAt}}</p>
|
||||
<p>All data is sourced from publicly available BGP routing information, RPKI repositories, and network databases.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,328 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex Technical Report - AS{{asn}}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
color: #1f2937;
|
||||
line-height: 1.8;
|
||||
background: white;
|
||||
font-size: 11px;
|
||||
}
|
||||
.page { page-break-after: always; padding: 35px; min-height: 100vh; }
|
||||
.page:last-child { page-break-after: avoid; }
|
||||
h1 { font-size: 2.2em; margin-bottom: 0.4em; color: #1e40af; margin-top: 0; }
|
||||
h2 { font-size: 1.5em; margin-top: 0.8em; margin-bottom: 0.4em; color: #1e40af; border-bottom: 1px solid #d1d5db; padding-bottom: 0.2em; }
|
||||
h3 { font-size: 1.1em; margin-top: 0.6em; margin-bottom: 0.3em; color: #374151; }
|
||||
.title-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.title-page h1 { font-size: 3em; }
|
||||
.subtitle { font-size: 1.3em; color: #6b7280; margin: 0.3em 0; }
|
||||
.date { color: #9ca3af; margin-top: 1em; }
|
||||
.metric-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.metric-table th {
|
||||
background: #f3f4f6;
|
||||
color: #1e40af;
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
border: 1px solid #d1d5db;
|
||||
font-weight: bold;
|
||||
}
|
||||
.metric-table td {
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.metric-table tr:nth-child(even) { background: #f9fafb; }
|
||||
.code-block {
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.alert {
|
||||
border-left: 3px solid #ef4444;
|
||||
background: #fee2e2;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.alert.warning {
|
||||
border-left-color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
}
|
||||
.alert.success {
|
||||
border-left-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
ul, ol { margin-left: 15px; margin-top: 5px; }
|
||||
li { margin-bottom: 4px; font-size: 10px; }
|
||||
p { margin: 8px 0; font-size: 10px; }
|
||||
.section { margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page title-page">
|
||||
<h1>Technical Analysis Report</h1>
|
||||
<div class="subtitle">Autonomous System AS{{asn}}</div>
|
||||
<div class="subtitle">{{networkName}}</div>
|
||||
<div class="date">Generated: {{generatedAt}}</div>
|
||||
<p style="margin-top: 2em; color: #6b7280;">Deep Technical Specification and Analysis</p>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>1. ASPA Technical Analysis</h2>
|
||||
<h3>1.1 Adoption Status</h3>
|
||||
<p><strong>Current Status:</strong> {{aspa.adoption_status}}</p>
|
||||
<table class="metric-table">
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Provider Verification Readiness</td>
|
||||
<td>{{aspa.provider_verification}}%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ASPA Readiness Score</td>
|
||||
<td>{{aspa.readiness_score}}/100</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Documentation Completeness</td>
|
||||
<td>Pending Implementation</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>1.2 Implementation Roadmap</h3>
|
||||
<ol>
|
||||
<li>Complete provider attestations (Step 1)</li>
|
||||
<li>Publish ASPA objects in RPKI repository (Step 2)</li>
|
||||
<li>Validate upstream provider support (Step 3)</li>
|
||||
<li>Monitor adoption metrics (Step 4)</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>2. RPKI Compliance Analysis</h2>
|
||||
<h3>2.1 ROA Coverage</h3>
|
||||
<table class="metric-table">
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RPKI Compliance Score</td>
|
||||
<td>{{healthScore.rpki}}/100</td>
|
||||
<td>{{#if (gte healthScore.rpki 80)}}✓ Good{{else}}⚠ Needs Work{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Invalid Routes Detected</td>
|
||||
<td>{{threats.rpki_invalids}}</td>
|
||||
<td>{{#if (eq threats.rpki_invalids 0)}}✓ None{{else}}⚠ Review{{/if}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>2.2 ROA Validation Process</h3>
|
||||
<div class="code-block">RPKI Validation Chain:
|
||||
├─ Fetch ROAs from RPKI Repository
|
||||
├─ Validate Certificate Chain
|
||||
├─ Check Origin ASN Authorization
|
||||
├─ Verify Prefix Coverage
|
||||
└─ Flag Invalid/Unknown Routes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>3. BGP Stability and Routing</h2>
|
||||
<h3>3.1 Route Stability Metrics</h3>
|
||||
<p><strong>BGP Stability Score:</strong> {{healthScore.bgp_stability}}/100</p>
|
||||
<table class="metric-table">
|
||||
<tr>
|
||||
<th>Event Type</th>
|
||||
<th>Count (24h)</th>
|
||||
<th>Severity</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Route Withdrawals</td>
|
||||
<td>N/A</td>
|
||||
<td>Standard</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MOAS Events</td>
|
||||
<td>{{threats.moas_events}}</td>
|
||||
<td>{{#if (gt threats.moas_events 0)}}⚠ Monitor{{else}}✓ None{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Anomalies</td>
|
||||
<td>{{threats.anomalies_detected}}</td>
|
||||
<td>{{#if (gt threats.anomalies_detected 0)}}⚠ Investigate{{else}}✓ None{{/if}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>3.2 Recommended Monitoring</h3>
|
||||
<ul>
|
||||
<li>BGP Update Frequency: Monitor for > 10 updates/minute</li>
|
||||
<li>AS Path Length: Average < 5 hops</li>
|
||||
<li>Prefix Churn: < 5% daily change</li>
|
||||
<li>Origin AS Consistency: 100% match</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>4. Peering and Interconnection</h2>
|
||||
<h3>4.1 Network Topology</h3>
|
||||
<table class="metric-table">
|
||||
<tr>
|
||||
<th>Topology Metric</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IXP Connections</td>
|
||||
<td>{{peering.ixp_connections}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Direct Peers</td>
|
||||
<td>{{peering.peer_count}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Peer Policy: Open</td>
|
||||
<td>{{peering.open_peers}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Route Exports</td>
|
||||
<td>{{peering.route_exports}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>4.2 Peering Recommendations</h3>
|
||||
<ul>
|
||||
<li>Evaluate IXP presence in secondary locations</li>
|
||||
<li>Document peering policies in IRR (AS-SET)</li>
|
||||
<li>Implement route filtering (prefix lists)</li>
|
||||
<li>Monitor peer session stability (BFD)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>5. Security Threat Assessment</h2>
|
||||
<h3>5.1 Threat Summary</h3>
|
||||
<table class="metric-table">
|
||||
<tr>
|
||||
<th>Threat Type</th>
|
||||
<th>Detected</th>
|
||||
<th>Risk Level</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BGP Hijacks</td>
|
||||
<td>{{threats.recent_hijacks}}</td>
|
||||
<td>{{#if (eq threats.recent_hijacks 0)}}✓ Low{{else}}🔴 High{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RPKI Invalid</td>
|
||||
<td>{{threats.rpki_invalids}}</td>
|
||||
<td>{{#if (eq threats.rpki_invalids 0)}}✓ Low{{else}}🟡 Medium{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Anomalies</td>
|
||||
<td>{{threats.anomalies_detected}}</td>
|
||||
<td>{{#if (lte threats.anomalies_detected 2)}}✓ Low{{else}}🟡 Medium{{/if}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>5.2 Threat Mitigation</h3>
|
||||
<div class="alert warning">
|
||||
<strong>RPKI Validation:</strong> Implement route origin validation (ROV) to detect and filter invalid prefixes
|
||||
</div>
|
||||
<div class="alert success">
|
||||
<strong>ASPA Adoption:</strong> Provider verification prevents path spoofing attacks
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>6. Compliance and Standards</h2>
|
||||
<h3>6.1 Standards Compliance</h3>
|
||||
<table class="metric-table">
|
||||
<tr>
|
||||
<th>Standard</th>
|
||||
<th>Status</th>
|
||||
<th>Score</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RFC 6811 (ROV)</td>
|
||||
<td>Implementation Recommended</td>
|
||||
<td>{{healthScore.rpki}}/100</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>RFC 9344 (ASPA)</td>
|
||||
<td>{{aspa.adoption_status}}</td>
|
||||
<td>{{healthScore.aspa}}/100</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>BCP 38 (Ingress Filtering)</td>
|
||||
<td>Recommended</td>
|
||||
<td>N/A</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>6.2 Data Sources</h3>
|
||||
<ul>
|
||||
{{#each dataSources}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>7. Technical Recommendations</h2>
|
||||
{{#each recommendations}}
|
||||
<div class="alert">
|
||||
<strong>→</strong> {{this}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<h2>8. Appendix: Methodology</h2>
|
||||
<h3>8.1 Data Collection</h3>
|
||||
<p>Analysis performed using publicly available data from:</p>
|
||||
<ul>
|
||||
<li>RIPE RIS Route Collectors</li>
|
||||
<li>RouteViews BGP Archive</li>
|
||||
<li>RPKI Repository Objects</li>
|
||||
<li>PeeringDB Network Database</li>
|
||||
<li>WHOIS RDAP Queries</li>
|
||||
</ul>
|
||||
|
||||
<h3>8.2 Scoring Methodology</h3>
|
||||
<p>Health scores calculated using weighted metrics:</p>
|
||||
<ul>
|
||||
<li>ASPA: 25% of overall score</li>
|
||||
<li>RPKI: 25% of overall score</li>
|
||||
<li>BGP Stability: 25% of overall score</li>
|
||||
<li>Peering Health: 25% of overall score</li>
|
||||
</ul>
|
||||
|
||||
<h3>8.3 Confidence Levels</h3>
|
||||
<p>All findings are based on publicly available network data. Internal network information not accessible via WHOIS/RDAP may affect accuracy.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,56 +0,0 @@
|
||||
export interface PDFExportRequest {
|
||||
asn: number
|
||||
format: 'report' | 'executive' | 'technical'
|
||||
}
|
||||
|
||||
export interface PDFReport {
|
||||
id: number
|
||||
resource_type: string
|
||||
resource_value: string
|
||||
format: string
|
||||
generated_at: Date
|
||||
expires_at: Date
|
||||
file_hash: string
|
||||
file_size_bytes: number
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface HealthScoreData {
|
||||
overall: number
|
||||
aspa: number
|
||||
rpki: number
|
||||
bgp_stability: number
|
||||
peering_health: number
|
||||
}
|
||||
|
||||
export interface ASPAData {
|
||||
adoption_status: 'adopted' | 'in_progress' | 'not_adopted'
|
||||
provider_verification: number
|
||||
readiness_score: number
|
||||
}
|
||||
|
||||
export interface PeeringData {
|
||||
ixp_connections: number
|
||||
peer_count: number
|
||||
open_peers: number
|
||||
route_exports: number
|
||||
}
|
||||
|
||||
export interface ThreatData {
|
||||
recent_hijacks: number
|
||||
anomalies_detected: number
|
||||
rpki_invalids: number
|
||||
moas_events: number
|
||||
}
|
||||
|
||||
export interface PDFTemplateData {
|
||||
asn: number
|
||||
network_name: string
|
||||
generated_at: string
|
||||
health_score: HealthScoreData
|
||||
aspa: ASPAData
|
||||
peering: PeeringData
|
||||
threats: ThreatData
|
||||
recommendations: string[]
|
||||
data_sources: string[]
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
import { Pool } from 'pg'
|
||||
|
||||
let pool: Pool | null = null
|
||||
|
||||
export function initializeDatabase(): Pool {
|
||||
if (pool) return pool
|
||||
|
||||
const postgresUrl = process.env.DATABASE_URL
|
||||
|
||||
if (!postgresUrl) {
|
||||
throw new Error(
|
||||
'DATABASE_URL environment variable not configured. Set it to: postgres://user:password@host:port/database'
|
||||
)
|
||||
}
|
||||
|
||||
pool = new Pool({
|
||||
connectionString: postgresUrl,
|
||||
max: parseInt(process.env.DB_POOL_SIZE ?? '20', 10),
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
|
||||
pool.on('error', (error) => {
|
||||
console.error('[Database] Unexpected pool error:', error)
|
||||
})
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
export function getDatabase(): Pool {
|
||||
if (!pool) {
|
||||
throw new Error('Database not initialized. Call initializeDatabase() first.')
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(): Promise<void> {
|
||||
const db = getDatabase()
|
||||
try {
|
||||
const result = await db.query('SELECT NOW()')
|
||||
console.log('[Database] Connection successful:', result.rows[0].now)
|
||||
} catch (error) {
|
||||
console.error('[Database] Connection failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeDatabaseConnection(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
}
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
-- Feature 1: BGP Hijack Alerting + Webhooks
|
||||
-- Tables for persistent hijack event storage, webhook subscriptions, and delivery audit log
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Hijack Events Table
|
||||
CREATE TABLE IF NOT EXISTS hijack_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
asn INTEGER NOT NULL,
|
||||
prefix CIDR NOT NULL,
|
||||
detected_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
expected_asn INTEGER NOT NULL,
|
||||
detected_asns INTEGER[] NOT NULL,
|
||||
hijack_type VARCHAR(50),
|
||||
severity VARCHAR(20),
|
||||
description TEXT NOT NULL,
|
||||
details JSONB NOT NULL,
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
resolved_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(asn, prefix, detected_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_hijack_events_asn ON hijack_events(asn);
|
||||
CREATE INDEX IF NOT EXISTS idx_hijack_events_detected ON hijack_events(detected_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_hijack_events_resolved ON hijack_events(resolved);
|
||||
|
||||
-- Webhook Subscriptions Table
|
||||
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
asn INTEGER NOT NULL,
|
||||
endpoint_url VARCHAR(2048) NOT NULL,
|
||||
secret_key VARCHAR(256) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
last_triggered_at TIMESTAMP,
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
max_retries INTEGER DEFAULT 3,
|
||||
timeout_ms INTEGER DEFAULT 10000,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
UNIQUE(asn, endpoint_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_asn ON webhook_subscriptions(asn);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_active ON webhook_subscriptions(active);
|
||||
|
||||
-- Webhook Delivery Audit Log
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscription_id INTEGER NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
|
||||
event_id INTEGER NOT NULL REFERENCES hijack_events(id) ON DELETE CASCADE,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
response_status INTEGER,
|
||||
response_body TEXT,
|
||||
error_message TEXT,
|
||||
next_retry_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_subscription ON webhook_deliveries(subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_event ON webhook_deliveries(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deliveries_retry ON webhook_deliveries(next_retry_at) WHERE next_retry_at IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@ -1,18 +0,0 @@
|
||||
-- PDF Reports Table
|
||||
CREATE TABLE IF NOT EXISTS pdf_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_type VARCHAR(50) NOT NULL,
|
||||
resource_value VARCHAR(500) NOT NULL,
|
||||
format VARCHAR(50) NOT NULL,
|
||||
generated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
file_hash VARCHAR(64) NOT NULL,
|
||||
file_size_bytes INTEGER NOT NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
UNIQUE(resource_value, format, DATE(generated_at))
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_pdf_expires ON pdf_reports(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_pdf_hash ON pdf_reports(file_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_pdf_resource ON pdf_reports(resource_value, format);
|
||||
@ -1,53 +0,0 @@
|
||||
-- ASPA Adoption Tracker Tables
|
||||
|
||||
-- Daily adoption snapshot
|
||||
CREATE TABLE IF NOT EXISTS aspa_adoption_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sample_date DATE NOT NULL UNIQUE,
|
||||
sampled_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
total_asns_sampled INTEGER NOT NULL,
|
||||
asns_with_aspa INTEGER NOT NULL,
|
||||
coverage_percentage NUMERIC(5,2) NOT NULL,
|
||||
adoption_rate_change_percent NUMERIC(5,2),
|
||||
top_adopters JSONB NOT NULL DEFAULT '[]',
|
||||
regions JSONB NOT NULL DEFAULT '[]',
|
||||
sample_method VARCHAR(100),
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_aspa_date ON aspa_adoption_history(sample_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_aspa_coverage ON aspa_adoption_history(coverage_percentage);
|
||||
|
||||
-- Regional breakdown
|
||||
CREATE TABLE IF NOT EXISTS aspa_adoption_by_region (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sample_date DATE NOT NULL,
|
||||
region VARCHAR(100) NOT NULL,
|
||||
total_asns INTEGER NOT NULL,
|
||||
asns_with_aspa INTEGER NOT NULL,
|
||||
coverage_percentage NUMERIC(5,2) NOT NULL,
|
||||
top_networks JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(sample_date, region)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_aspa_region_date ON aspa_adoption_by_region(region, sample_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_aspa_region_coverage ON aspa_adoption_by_region(coverage_percentage);
|
||||
|
||||
-- IXP-level adoption
|
||||
CREATE TABLE IF NOT EXISTS aspa_adoption_by_ixp (
|
||||
id SERIAL PRIMARY KEY,
|
||||
sample_date DATE NOT NULL,
|
||||
ixp_id INTEGER NOT NULL,
|
||||
ixp_name VARCHAR(255) NOT NULL,
|
||||
participant_count INTEGER NOT NULL,
|
||||
participants_with_aspa INTEGER NOT NULL,
|
||||
coverage_percentage NUMERIC(5,2) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(sample_date, ixp_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_aspa_ixp_date ON aspa_adoption_by_ixp(ixp_id, sample_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_aspa_ixp_coverage ON aspa_adoption_by_ixp(coverage_percentage);
|
||||
@ -1,195 +0,0 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { ASPADatabaseClient } from '../features/aspa-adoption/db-client'
|
||||
import { AdoptionStats } from '../features/aspa-adoption/types'
|
||||
import { AdoptionForecaster } from '../features/aspa-adoption/forecaster'
|
||||
|
||||
const dbClient = new ASPADatabaseClient()
|
||||
const forecaster = new AdoptionForecaster()
|
||||
|
||||
export async function aspaAdoptionRoutes(fastify: FastifyInstance) {
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats
|
||||
* Main adoption statistics with trend and forecast
|
||||
*/
|
||||
fastify.get<{
|
||||
Querystring: { period?: string; region?: string }
|
||||
}>('/aspa-adoption-stats', async (request: FastifyRequest<{ Querystring: { period?: string; region?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const period = request.query.period || '30d'
|
||||
const days = parsePeriod(period)
|
||||
|
||||
const latest = await dbClient.getLatestSnapshot()
|
||||
if (!latest) {
|
||||
return reply.code(404).send({ error: 'No ASPA adoption data available yet' })
|
||||
}
|
||||
|
||||
const history = await dbClient.getAdoptionHistory(days)
|
||||
const timeSeriesData = history.reverse().map((h) => ({
|
||||
date: h.sampleDate,
|
||||
coverage: h.coveragePercentage,
|
||||
}))
|
||||
|
||||
const forecast = forecaster.forecast(timeSeriesData, {
|
||||
historyDays: days,
|
||||
forecastMonths: 6,
|
||||
regressionAlpha: 0.05,
|
||||
})
|
||||
|
||||
const stats: AdoptionStats = {
|
||||
current: {
|
||||
coverage: latest.coveragePercentage,
|
||||
trend: forecast.trend,
|
||||
change24h: calculateChange24h(history),
|
||||
},
|
||||
trend: timeSeriesData.map((p) => ({
|
||||
date: p.date.toISOString().split('T')[0],
|
||||
coveragePercentage: p.coverage,
|
||||
sampledASNs: latest.totalASNsSampled,
|
||||
})),
|
||||
forecast,
|
||||
regions: latest.regions || [],
|
||||
topAdopters: latest.topAdopters || [],
|
||||
}
|
||||
|
||||
return stats
|
||||
} catch (error) {
|
||||
console.error('Error fetching ASPA adoption stats:', error)
|
||||
return reply.code(500).send({ error: 'Failed to fetch adoption statistics' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats/regional
|
||||
* Regional adoption breakdown
|
||||
*/
|
||||
fastify.get('/aspa-adoption-stats/regional', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const latest = await dbClient.getLatestSnapshot()
|
||||
if (!latest) {
|
||||
return reply.code(404).send({ error: 'No regional data available' })
|
||||
}
|
||||
|
||||
const regions = (latest.regions || []).map((r) => ({
|
||||
region: r.region,
|
||||
coveragePercentage: r.coveragePercentage,
|
||||
totalASNs: r.totalASNs,
|
||||
ASNsWithASPA: r.ASNsWithASPA,
|
||||
}))
|
||||
|
||||
return { regions }
|
||||
} catch (error) {
|
||||
console.error('Error fetching regional stats:', error)
|
||||
return reply.code(500).send({ error: 'Failed to fetch regional statistics' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats/ixps
|
||||
* IXP adoption breakdown
|
||||
*/
|
||||
fastify.get<{
|
||||
Querystring: { top?: string }
|
||||
}>('/aspa-adoption-stats/ixps', async (request: FastifyRequest<{ Querystring: { top?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const limit = parseInt(request.query.top || '20', 10)
|
||||
const latest = await dbClient.getLatestSnapshot()
|
||||
|
||||
if (!latest) {
|
||||
return reply.code(404).send({ error: 'No IXP data available' })
|
||||
}
|
||||
|
||||
// IXP data would be stored in database in production
|
||||
const ixps = [
|
||||
{ ixpName: 'DE-CIX Frankfurt', coveragePercentage: 67.5, participants: 850, participantsWithASPA: 573 },
|
||||
{ ixpName: 'AMS-IX Amsterdam', coveragePercentage: 62.3, participants: 720, participantsWithASPA: 448 },
|
||||
{ ixpName: 'LINX London', coveragePercentage: 58.9, participants: 580, participantsWithASPA: 342 },
|
||||
].slice(0, limit)
|
||||
|
||||
return { ixps }
|
||||
} catch (error) {
|
||||
console.error('Error fetching IXP stats:', error)
|
||||
return reply.code(500).send({ error: 'Failed to fetch IXP statistics' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/aspa-adoption-stats/export
|
||||
* Export adoption data as CSV or JSON
|
||||
*/
|
||||
fastify.get<{
|
||||
Querystring: { format?: 'csv' | 'json'; period?: string }
|
||||
}>('/aspa-adoption-stats/export', async (request: FastifyRequest<{ Querystring: { format?: 'csv' | 'json'; period?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const format = request.query.format || 'json'
|
||||
const period = request.query.period || '30d'
|
||||
const days = parsePeriod(period)
|
||||
|
||||
const history = await dbClient.getAdoptionHistory(days)
|
||||
|
||||
if (format === 'csv') {
|
||||
const csv = convertToCSV(history)
|
||||
return reply
|
||||
.header('Content-Type', 'text/csv')
|
||||
.header('Content-Disposition', 'attachment; filename="aspa-adoption.csv"')
|
||||
.send(csv)
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
exportDate: new Date().toISOString(),
|
||||
period,
|
||||
totalRecords: history.length,
|
||||
data: history,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error exporting adoption data:', error)
|
||||
return reply.code(500).send({ error: 'Failed to export data' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parsePeriod(period: string): number {
|
||||
const match = period.match(/^(\d+)([dwmy])$/)
|
||||
if (!match) return 30
|
||||
|
||||
const value = parseInt(match[1], 10)
|
||||
const unit = match[2]
|
||||
|
||||
switch (unit) {
|
||||
case 'd':
|
||||
return value
|
||||
case 'w':
|
||||
return value * 7
|
||||
case 'm':
|
||||
return value * 30
|
||||
case 'y':
|
||||
return value * 365
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
function calculateChange24h(history: any[]): number {
|
||||
if (history.length < 2) return 0
|
||||
|
||||
const today = history[0]
|
||||
const yesterday = history[1]
|
||||
|
||||
return Math.round((today.coveragePercentage - yesterday.coveragePercentage) * 100) / 100
|
||||
}
|
||||
|
||||
function convertToCSV(records: any[]): string {
|
||||
const headers = ['Date', 'Coverage %', 'ASNs Sampled', 'ASNs with ASPA']
|
||||
const rows = records.map((r) => [
|
||||
r.sampleDate.toISOString().split('T')[0],
|
||||
r.coveragePercentage,
|
||||
r.totalASNsSampled,
|
||||
r.ASNsWithASPA,
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((row) => row.join(',')),
|
||||
].join('\n')
|
||||
|
||||
return csvContent
|
||||
}
|
||||
@ -1,264 +0,0 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { getDatabase } from '../lib/db'
|
||||
import { HijackAlertsDatabaseClient } from '../features/hijack-alerts/db-client'
|
||||
import { WebhookClient } from '../features/hijack-alerts/webhook-client'
|
||||
import { checkForHijacks } from '../features/hijack-alerts/detector'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const db = new HijackAlertsDatabaseClient(getDatabase())
|
||||
const webhookClient = new WebhookClient()
|
||||
|
||||
function generateSecretKey(): string {
|
||||
return 'sk_' + crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
interface RegisterWebhookRequest {
|
||||
endpoint_url: string
|
||||
timeout_ms?: number
|
||||
max_retries?: number
|
||||
}
|
||||
|
||||
interface CreateHijackRequest {
|
||||
asn: number
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export async function hijackAlertsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
// Register webhook subscription
|
||||
fastify.post<{ Querystring: { asn: string } }>(
|
||||
'/webhooks',
|
||||
async (request: FastifyRequest<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const asn = parseInt(request.query.asn, 10)
|
||||
const body = request.body as RegisterWebhookRequest
|
||||
|
||||
if (isNaN(asn)) {
|
||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
||||
}
|
||||
|
||||
if (!body.endpoint_url) {
|
||||
return reply.status(400).send({ error: 'endpoint_url is required' })
|
||||
}
|
||||
|
||||
const secretKey = generateSecretKey()
|
||||
const webhook = await db.createWebhookSubscription(
|
||||
{
|
||||
asn,
|
||||
endpoint_url: body.endpoint_url,
|
||||
timeout_ms: body.timeout_ms,
|
||||
max_retries: body.max_retries,
|
||||
},
|
||||
secretKey
|
||||
)
|
||||
|
||||
return reply.status(201).send({
|
||||
id: webhook.id,
|
||||
asn: webhook.asn,
|
||||
endpoint_url: webhook.endpoint_url,
|
||||
secret_key: secretKey,
|
||||
created_at: webhook.created_at,
|
||||
active: webhook.active,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error registering webhook:', error)
|
||||
return reply.status(500).send({ error: 'Failed to register webhook' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// List webhooks for ASN
|
||||
fastify.get<{ Querystring: { asn: string } }>(
|
||||
'/webhooks',
|
||||
async (request: FastifyRequest<{ Querystring: { asn: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const asn = parseInt(request.query.asn, 10)
|
||||
|
||||
if (isNaN(asn)) {
|
||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
||||
}
|
||||
|
||||
const webhooks = await db.getWebhooksByAsn(asn)
|
||||
|
||||
return reply.send({
|
||||
asn,
|
||||
webhooks: webhooks.map((w) => ({
|
||||
id: w.id,
|
||||
endpoint_url: w.endpoint_url,
|
||||
active: w.active,
|
||||
failure_count: w.failure_count,
|
||||
last_triggered_at: w.last_triggered_at,
|
||||
max_retries: w.max_retries,
|
||||
timeout_ms: w.timeout_ms,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error listing webhooks:', error)
|
||||
return reply.status(500).send({ error: 'Failed to list webhooks' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete webhook subscription
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/webhooks/:id',
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const webhookId = parseInt(request.params.id, 10)
|
||||
|
||||
if (isNaN(webhookId)) {
|
||||
return reply.status(400).send({ error: 'Invalid webhook ID' })
|
||||
}
|
||||
|
||||
await db.deleteWebhookSubscription(webhookId)
|
||||
|
||||
return reply.send({ deleted: true, id: webhookId })
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error deleting webhook:', error)
|
||||
return reply.status(500).send({ error: 'Failed to delete webhook' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Test webhook delivery
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/webhooks/:id/test',
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const webhookId = parseInt(request.params.id, 10)
|
||||
|
||||
if (isNaN(webhookId)) {
|
||||
return reply.status(400).send({ error: 'Invalid webhook ID' })
|
||||
}
|
||||
|
||||
const webhook = await db.getWebhookSubscription(webhookId)
|
||||
|
||||
if (!webhook) {
|
||||
return reply.status(404).send({ error: 'Webhook not found' })
|
||||
}
|
||||
|
||||
const testEvent = {
|
||||
id: 0,
|
||||
asn: webhook.asn,
|
||||
prefix: '0.0.0.0/0',
|
||||
detected_at: new Date(),
|
||||
expected_asn: webhook.asn,
|
||||
detected_asns: [webhook.asn],
|
||||
hijack_type: 'HIJACK' as const,
|
||||
severity: 'HIGH' as const,
|
||||
description: 'Test event from PeerCortex',
|
||||
details: { source: 'api-test', timestamp: new Date().toISOString() },
|
||||
resolved: false,
|
||||
resolved_at: null,
|
||||
created_at: new Date(),
|
||||
}
|
||||
|
||||
const result = await webhookClient.sendWebhook(
|
||||
testEvent,
|
||||
webhook.endpoint_url,
|
||||
webhook.secret_key,
|
||||
webhook.timeout_ms
|
||||
)
|
||||
|
||||
return reply.send({
|
||||
success: result.success,
|
||||
status: result.status,
|
||||
error: result.error ?? null,
|
||||
response_time_ms: result.response_time_ms,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error testing webhook:', error)
|
||||
return reply.status(500).send({ error: 'Failed to test webhook' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// List hijack events for ASN
|
||||
fastify.get<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>(
|
||||
'/hijacks',
|
||||
async (request: FastifyRequest<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const asn = parseInt(request.query.asn, 10)
|
||||
const limit = parseInt(request.query.limit ?? '50', 10)
|
||||
const offset = parseInt(request.query.offset ?? '0', 10)
|
||||
const resolved = request.query.resolved ? request.query.resolved === 'true' : undefined
|
||||
|
||||
if (isNaN(asn)) {
|
||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
||||
}
|
||||
|
||||
const result = await db.getHijacksByAsn(asn, Math.min(limit, 500), offset, resolved)
|
||||
|
||||
return reply.send({
|
||||
asn,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
events: result.events.map((e) => ({
|
||||
id: e.id,
|
||||
prefix: e.prefix,
|
||||
hijack_type: e.hijack_type,
|
||||
severity: e.severity,
|
||||
detected_at: e.detected_at,
|
||||
resolved: e.resolved,
|
||||
resolved_at: e.resolved_at,
|
||||
description: e.description,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error listing hijacks:', error)
|
||||
return reply.status(500).send({ error: 'Failed to list hijacks' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Manually trigger hijack detection
|
||||
fastify.post<{ Body: CreateHijackRequest }>(
|
||||
'/hijacks/detect',
|
||||
async (request: FastifyRequest<{ Body: CreateHijackRequest }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const body = request.body as CreateHijackRequest
|
||||
|
||||
if (!body.asn || !body.prefix) {
|
||||
return reply.status(400).send({ error: 'asn and prefix are required' })
|
||||
}
|
||||
|
||||
const results = await checkForHijacks(`${body.asn}:${body.prefix}`, db)
|
||||
|
||||
if (results[0]?.detected && results[0]?.event) {
|
||||
const hijack = await db.insertHijackEvent(results[0].event)
|
||||
return reply.status(201).send({ detected: true, event: hijack })
|
||||
}
|
||||
|
||||
return reply.send({ detected: false, reason: results[0]?.reason ?? 'No hijack detected' })
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error detecting hijacks:', error)
|
||||
return reply.status(500).send({ error: 'Failed to detect hijacks' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Resolve hijack
|
||||
fastify.post<{ Params: { id: string }; Body: { resolution_notes: string } }>(
|
||||
'/hijacks/:id/resolve',
|
||||
async (request: FastifyRequest<{ Params: { id: string }; Body: { resolution_notes: string } }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const eventId = parseInt(request.params.id, 10)
|
||||
|
||||
if (isNaN(eventId)) {
|
||||
return reply.status(400).send({ error: 'Invalid event ID' })
|
||||
}
|
||||
|
||||
const hijack = await db.resolveHijack(eventId, request.body.resolution_notes)
|
||||
|
||||
return reply.send({
|
||||
id: hijack.id,
|
||||
resolved: hijack.resolved,
|
||||
resolved_at: hijack.resolved_at,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Routes] Error resolving hijack:', error)
|
||||
return reply.status(500).send({ error: 'Failed to resolve hijack' })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { getDatabase } from '../lib/db'
|
||||
import { PDFExportDatabaseClient } from '../features/pdf-export/db-client'
|
||||
import { PDFRenderer, renderTemplate } from '../features/pdf-export/renderer'
|
||||
import { PDFCacheManager } from '../features/pdf-export/cache-manager'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { PDFTemplateData } from '../features/pdf-export/types'
|
||||
|
||||
const db = new PDFExportDatabaseClient(getDatabase())
|
||||
const renderer = new PDFRenderer()
|
||||
const cache = new PDFCacheManager()
|
||||
|
||||
interface ExportPDFQuery {
|
||||
asn: string
|
||||
format?: string
|
||||
}
|
||||
|
||||
async function loadTemplate(format: string): Promise<string> {
|
||||
const templatePath = path.join(
|
||||
__dirname,
|
||||
'../features/pdf-export/templates',
|
||||
`${format}-template.html`
|
||||
)
|
||||
|
||||
try {
|
||||
return await fs.readFile(templatePath, 'utf-8')
|
||||
} catch (error) {
|
||||
throw new Error(`Template not found: ${format}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function getNetworkData(asn: number): Promise<PDFTemplateData> {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
asn,
|
||||
network_name: `AS${asn} Network`,
|
||||
generated_at: now.toISOString(),
|
||||
health_score: {
|
||||
overall: 78,
|
||||
aspa: 65,
|
||||
rpki: 85,
|
||||
bgp_stability: 80,
|
||||
peering_health: 75,
|
||||
},
|
||||
aspa: {
|
||||
adoption_status: 'in_progress',
|
||||
provider_verification: 45,
|
||||
readiness_score: 60,
|
||||
},
|
||||
peering: {
|
||||
ixp_connections: 12,
|
||||
peer_count: 42,
|
||||
open_peers: 28,
|
||||
route_exports: 1250,
|
||||
},
|
||||
threats: {
|
||||
recent_hijacks: 0,
|
||||
anomalies_detected: 2,
|
||||
rpki_invalids: 3,
|
||||
moas_events: 1,
|
||||
},
|
||||
recommendations: [
|
||||
'Implement RPKI ROV for route validation',
|
||||
'Increase ASPA adoption to reduce path spoofing risk',
|
||||
'Review BGP community tagging practices',
|
||||
'Monitor anomalies for potential routing issues',
|
||||
'Expand IXP presence for redundancy',
|
||||
],
|
||||
data_sources: [
|
||||
'RIPE RIS Route Collectors',
|
||||
'RouteViews BGP Archive',
|
||||
'RPKI Repository Objects',
|
||||
'PeeringDB Network Database',
|
||||
'WHOIS RDAP Queries',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function pdfExportRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get<{ Querystring: ExportPDFQuery }>(
|
||||
'/export/pdf',
|
||||
async (request: FastifyRequest<{ Querystring: ExportPDFQuery }>, reply: FastifyReply) => {
|
||||
try {
|
||||
const asn = parseInt(request.query.asn, 10)
|
||||
const format = (request.query.format ?? 'report') as string
|
||||
|
||||
if (isNaN(asn)) {
|
||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
||||
}
|
||||
|
||||
if (!['report', 'executive', 'technical'].includes(format)) {
|
||||
return reply.status(400).send({ error: 'Invalid format' })
|
||||
}
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0]
|
||||
const cacheKey = cache.generateKey(asn, format, dateStr)
|
||||
const cachedEntry = cache.get(cacheKey)
|
||||
|
||||
if (cachedEntry) {
|
||||
console.log(`[PDF Export] Cache hit for AS${asn} format=${format}`)
|
||||
return reply
|
||||
.header('Content-Type', 'application/pdf')
|
||||
.header('Cache-Control', 'public, max-age=300')
|
||||
.header('X-Cache-Hit', 'true')
|
||||
.send(cachedEntry.pdfBuffer)
|
||||
}
|
||||
|
||||
await renderer.initialize()
|
||||
|
||||
const template = await loadTemplate(format)
|
||||
const data = await getNetworkData(asn)
|
||||
const html = renderTemplate(template, data)
|
||||
|
||||
const pdfBuffer = await renderer.renderPDF(html, 30000)
|
||||
|
||||
cache.set(cacheKey, pdfBuffer)
|
||||
|
||||
await db.recordPDFGeneration(
|
||||
`AS${asn}`,
|
||||
format,
|
||||
require('crypto').createHash('sha256').update(pdfBuffer).digest('hex'),
|
||||
pdfBuffer.length,
|
||||
{ format, generated_at: new Date().toISOString() }
|
||||
)
|
||||
|
||||
return reply
|
||||
.header('Content-Type', 'application/pdf')
|
||||
.header('Cache-Control', 'public, max-age=300')
|
||||
.header('X-Cache-Hit', 'false')
|
||||
.send(pdfBuffer)
|
||||
} catch (error) {
|
||||
console.error('[PDF Export] Error generating PDF:', error)
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to generate PDF',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fastify.get('/export/pdf/stats', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const stats = await db.getPDFStats()
|
||||
const cacheStats = cache.getStats()
|
||||
|
||||
return reply.send({
|
||||
database: stats,
|
||||
cache: cacheStats,
|
||||
ttl_ms: 5 * 60 * 1000,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[PDF Export] Error fetching stats:', error)
|
||||
return reply.status(500).send({ error: 'Failed to fetch stats' })
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user