From f2470f3e56f21d80ab007dcdee0f86a074af2122 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Thu, 26 Mar 2026 07:26:14 +1300 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20release=20=E2=80=94=20AI-powe?= =?UTF-8?q?red=20network=20intelligence=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeerCortex unifies PeeringDB, RIPE Stat, bgproutes.io, RIPE Atlas, Route Views, IRR, RPKI, and CAIDA into a single AI-queryable MCP Server for network engineers. Powered by local Ollama. Core capabilities: - 34 MCP tools for network intelligence - 11 data sources unified - ASPA validation engine (RFC 9582) with leak detection - Peering partner discovery with AI-ranked matches - BGP analysis and anomaly detection - RPKI monitoring and compliance reports - Latency/traceroute via RIPE Atlas - Transit analysis and cost comparison - IX traffic statistics - AS topology mapping - ASPA object generator and simulator - 100% local AI — no cloud dependencies --- .env.example | 42 ++ .gitignore | 36 ++ Dockerfile | 35 ++ LICENSE | 21 + README.md | 995 +++++++++++++++++++++++++++++++ docker-compose.yml | 69 +++ docs/architecture.md | 89 +++ docs/assets/demo.svg | 180 ++++++ docs/assets/peercortex-logo.svg | 109 ++++ docs/data-sources.md | 95 +++ docs/setup.md | 108 ++++ package.json | 85 +++ src/ai/ollama.ts | 205 +++++++ src/ai/prompts.ts | 225 +++++++ src/aspa/coverage.ts | 402 +++++++++++++ src/aspa/generator.ts | 278 +++++++++ src/aspa/leak-detector.ts | 450 ++++++++++++++ src/aspa/objects.ts | 366 ++++++++++++ src/aspa/simulator.ts | 324 ++++++++++ src/aspa/validator.ts | 332 +++++++++++ src/cache/store.ts | 235 ++++++++ src/mcp-server/index.ts | 608 +++++++++++++++++++ src/mcp-server/tools/aspa.ts | 563 +++++++++++++++++ src/mcp-server/tools/atlas.ts | 234 ++++++++ src/mcp-server/tools/bgp.ts | 209 +++++++ src/mcp-server/tools/compare.ts | 138 +++++ src/mcp-server/tools/dns.ts | 204 +++++++ src/mcp-server/tools/latency.ts | 201 +++++++ src/mcp-server/tools/lookup.ts | 174 ++++++ src/mcp-server/tools/peering.ts | 163 +++++ src/mcp-server/tools/report.ts | 167 ++++++ src/mcp-server/tools/rpki.ts | 170 ++++++ src/mcp-server/tools/security.ts | 322 ++++++++++ src/mcp-server/tools/topology.ts | 233 ++++++++ src/mcp-server/tools/traffic.ts | 225 +++++++ src/mcp-server/tools/transit.ts | 251 ++++++++ src/sources/bgp-he.ts | 216 +++++++ src/sources/bgproutes-io.ts | 304 ++++++++++ src/sources/caida.ts | 335 +++++++++++ src/sources/dns.ts | 385 ++++++++++++ src/sources/irr.ts | 254 ++++++++ src/sources/ix-traffic.ts | 314 ++++++++++ src/sources/peeringdb.ts | 238 ++++++++ src/sources/ripe-atlas.ts | 405 +++++++++++++ src/sources/ripe-stat.ts | 206 +++++++ src/sources/route-views.ts | 233 ++++++++ src/sources/rpki.ts | 251 ++++++++ src/types/bgp.ts | 352 +++++++++++ src/types/common.ts | 313 ++++++++++ src/types/peeringdb.ts | 240 ++++++++ tsconfig.json | 25 + 51 files changed, 12614 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/architecture.md create mode 100644 docs/assets/demo.svg create mode 100644 docs/assets/peercortex-logo.svg create mode 100644 docs/data-sources.md create mode 100644 docs/setup.md create mode 100644 package.json create mode 100644 src/ai/ollama.ts create mode 100644 src/ai/prompts.ts create mode 100644 src/aspa/coverage.ts create mode 100644 src/aspa/generator.ts create mode 100644 src/aspa/leak-detector.ts create mode 100644 src/aspa/objects.ts create mode 100644 src/aspa/simulator.ts create mode 100644 src/aspa/validator.ts create mode 100644 src/cache/store.ts create mode 100644 src/mcp-server/index.ts create mode 100644 src/mcp-server/tools/aspa.ts create mode 100644 src/mcp-server/tools/atlas.ts create mode 100644 src/mcp-server/tools/bgp.ts create mode 100644 src/mcp-server/tools/compare.ts create mode 100644 src/mcp-server/tools/dns.ts create mode 100644 src/mcp-server/tools/latency.ts create mode 100644 src/mcp-server/tools/lookup.ts create mode 100644 src/mcp-server/tools/peering.ts create mode 100644 src/mcp-server/tools/report.ts create mode 100644 src/mcp-server/tools/rpki.ts create mode 100644 src/mcp-server/tools/security.ts create mode 100644 src/mcp-server/tools/topology.ts create mode 100644 src/mcp-server/tools/traffic.ts create mode 100644 src/mcp-server/tools/transit.ts create mode 100644 src/sources/bgp-he.ts create mode 100644 src/sources/bgproutes-io.ts create mode 100644 src/sources/caida.ts create mode 100644 src/sources/dns.ts create mode 100644 src/sources/irr.ts create mode 100644 src/sources/ix-traffic.ts create mode 100644 src/sources/peeringdb.ts create mode 100644 src/sources/ripe-atlas.ts create mode 100644 src/sources/ripe-stat.ts create mode 100644 src/sources/route-views.ts create mode 100644 src/sources/rpki.ts create mode 100644 src/types/bgp.ts create mode 100644 src/types/common.ts create mode 100644 src/types/peeringdb.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e01bcac --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# ───────────────────────────────────────────────────────────── +# PeerCortex Configuration +# Copy this file to .env and fill in your values +# ───────────────────────────────────────────────────────────── + +# ── Ollama (Local AI) ────────────────────────────────────── +# URL of your Ollama instance +OLLAMA_BASE_URL=http://localhost:11434 +# Model to use for AI analysis (recommended: llama3.1, mistral, codellama) +OLLAMA_MODEL=llama3.1 + +# ── PeeringDB ───────────────────────────────────────────── +# Optional: PeeringDB API key for higher rate limits +# Get one at: https://www.peeringdb.com/apidocs/ +PEERINGDB_API_KEY= + +# ── RIPE Stat ───────────────────────────────────────────── +# Optional: RIPE Stat data source key +# Register at: https://stat.ripe.net/ +RIPE_STAT_SOURCE_APP=peercortex + +# ── RPKI ────────────────────────────────────────────────── +# Routinator API endpoint (if running locally) +ROUTINATOR_URL=http://localhost:8323 +# RIPE RPKI Validator endpoint +RIPE_RPKI_VALIDATOR_URL=https://rpki-validator.ripe.net/api/v1 + +# ── Cache ───────────────────────────────────────────────── +# SQLite database path for caching API responses +CACHE_DB_PATH=./peercortex-cache.db +# Cache TTL in seconds (default: 1 hour) +CACHE_TTL_SECONDS=3600 + +# ── Server ──────────────────────────────────────────────── +# MCP Server transport (stdio or sse) +MCP_TRANSPORT=stdio +# Port for SSE transport (only used if MCP_TRANSPORT=sse) +MCP_PORT=3100 + +# ── Logging ─────────────────────────────────────────────── +# Log level: debug, info, warn, error +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3b1d59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Cache +.cache/ +peercortex.db +peercortex-cache.db + +# Coverage +coverage/ + +# TypeScript incremental +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2caf78d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# Copy source and build +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npm run build + +# ── Production image ────────────────────────────────────── +FROM node:22-alpine AS runtime + +WORKDIR /app + +# Install production dependencies only +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# Copy compiled output +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S peercortex && \ + adduser -S peercortex -u 1001 -G peercortex +USER peercortex + +# SQLite cache volume +VOLUME ["/app/data"] +ENV CACHE_DB_PATH=/app/data/peercortex-cache.db + +ENTRYPOINT ["node", "dist/mcp-server/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..28b30d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PeerCortex Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c722b8 --- /dev/null +++ b/README.md @@ -0,0 +1,995 @@ +

+ PeerCortex Logo +

+ +

PeerCortex

+

The AI-Powered Network Intelligence Platform

+ +

+ PeeringDB + RIPE Stat + BGP + MCP Server + Ollama + Self-hosted + TypeScript + MIT License +

+ +

+ AI-powered network intelligence. Query PeeringDB, analyze BGP, monitor RPKI,
+ find peering partners — all from Claude Code or any MCP client. 100% local. +

+ +--- + + +

+ Demo animation coming soon — see Claude Code Integration for example conversations. +

+ +--- + +## Table of Contents + +- [What is PeerCortex?](#what-is-peercortex) +- [The Problem](#the-problem) +- [Features](#features) + - [ASN Intelligence](#-asn-intelligence) + - [Peering Partner Discovery](#-peering-partner-discovery) + - [BGP Analysis & Anomaly Detection](#-bgp-analysis--anomaly-detection) + - [RPKI Monitoring & Compliance](#-rpki-monitoring--compliance) + - [Network Comparison](#-network-comparison) + - [Report Generation](#-report-generation) +- [MCP Server Tools](#mcp-server-tools) +- [Claude Code Integration](#claude-code-integration) +- [Data Sources](#data-sources) +- [Feature Comparison](#feature-comparison) +- [Architecture](#architecture) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Privacy & Security](#privacy--security) +- [Roadmap](#roadmap) +- [Contributing](#contributing) +- [FAQ](#faq) +- [Acknowledgments](#acknowledgments) +- [Ecosystem](#ecosystem) + +--- + +## What is PeerCortex? + +PeerCortex is an **MCP (Model Context Protocol) Server** that unifies six major network intelligence data sources into a single, AI-queryable interface for network engineers, peering coordinators, and NOC operators. + +Instead of switching between PeeringDB, RIPE Stat, bgp.he.net, Route Views, IRR databases, and RPKI validators — each with their own interfaces and query languages — PeerCortex lets you ask questions in **plain English** through Claude Code or any MCP-compatible client. + +A local **Ollama** instance provides AI analysis: ranking peering partners, detecting BGP anomalies, generating compliance reports, and drafting peering request emails. All inference runs on your machine. No data leaves your network. + +**Who is this for?** + +- **Network Engineers** who want instant answers from multiple data sources +- **Peering Coordinators** who need to find and evaluate peering partners +- **NOC Operators** who monitor BGP health and detect anomalies +- **Security Teams** who track RPKI compliance and route hijacks +- **Anyone** who works with Internet routing data and wants AI assistance + +--- + +## The Problem + +Network operators juggle fragmented tools. Every task requires a different interface: + +| Task | Without PeerCortex | With PeerCortex | +|------|-------------------|-----------------| +| ASN lookup | Open PeeringDB, RIPE Stat, bgp.he.net in separate tabs | `"Give me the full picture for AS13335"` | +| Find peering partners | Manual PeeringDB search, filter by IX, check policies | `"Find peering partners at DE-CIX with open policy"` | +| Detect route leaks | Check RIPE RIS, cross-reference AS paths, manual analysis | `"Any BGP anomalies for 185.1.0.0/24?"` | +| RPKI compliance | Query Routinator, match against announced prefixes, calculate coverage | `"Generate an RPKI compliance report for AS13335"` | +| Compare networks | Open both ASNs on PeeringDB, manually compare IX/facility lists | `"Compare AS13335 and AS32934"` | +| Peering request | Look up contacts, check common IXs, write email from scratch | `"Draft a peering request to AS714 for DE-CIX Frankfurt"` | + +PeerCortex collapses these multi-step workflows into **single natural language queries**, backed by real data from authoritative sources. + +--- + +## Features + +### 1. ASN Intelligence + +Unified ASN lookup that queries PeeringDB, RIPE Stat, bgp.he.net, IRR databases, and RPKI validators in parallel, returning a comprehensive network profile. + +**What you get:** +- Network name, type, scope, and peering policy +- Announced prefix counts (IPv4 + IPv6) +- IX participation with connection speeds +- Facility/colocation presence +- IRR registration status (as-set, route objects) +- RPKI ROA coverage and validation summary +- Upstream providers and peer count +- Contact information for peering + +**Example:** +``` +You: Give me the full picture for AS13335 + +PeerCortex: Here's the comprehensive profile for AS13335 (Cloudflare, Inc.): + + Network Type: Content + Peering Policy: Open + Scope: Global + Prefixes: 1,200+ IPv4, 200+ IPv6 + IXs: 290+ exchanges worldwide + Facilities: 320+ data centers + RPKI Coverage: 99.8% (all prefixes covered by ROAs) + IRR: AS-CLOUDFLARE registered in RADB + ... +``` + +### 2. Peering Partner Discovery + +AI-ranked peering partner recommendations based on common IX presence, facility overlap, policy compatibility, and network type complementarity. + +**What you get:** +- Ranked list of potential peering partners +- Match score with reasoning +- Common IXs and facilities +- Peering policy compatibility +- Contact information +- Draft peering request emails + +**Example:** +``` +You: Find peering partners for AS13335 at DE-CIX Frankfurt with open policy + +PeerCortex: Found 47 networks at DE-CIX Frankfurt with open peering policy. +Top matches for AS13335: + + 1. AS32934 (Facebook) — Score: 92/100 + Common IXs: DE-CIX Frankfurt, AMS-IX, LINX + Common Facilities: Equinix FR5, Interxion FRA + Policy: Open | Type: Content + Why: Major content network, high traffic overlap potential + + 2. AS15169 (Google) — Score: 88/100 + Common IXs: DE-CIX Frankfurt, AMS-IX, LINX, JPNAP + ... +``` + +### 3. BGP Analysis & Anomaly Detection + +Real-time BGP analysis using data from RIPE Stat RIS collectors, Route Views, and bgp.he.net. Detects route leaks, BGP hijacks, MOAS conflicts, and path anomalies. + +**What you get:** +- AS path analysis and diversity metrics +- Route origin validation +- Anomaly detection with severity levels +- Route leak identification +- BGP hijack alerts +- MOAS (Multiple Origin AS) conflict detection +- Prefix visibility reports +- AI-generated routing assessment + +**Example:** +``` +You: Are there any BGP anomalies for 185.1.0.0/24 right now? + +PeerCortex: BGP analysis for 185.1.0.0/24: + + Origin AS: AS47541 + Path Diversity: 12 unique paths from 24 collectors + Visibility: 98.5% (seen by 394/400 RIS peers) + RPKI Status: Valid (ROA match: 185.1.0.0/24, max /24, AS47541) + + Anomalies Detected: None + No route leaks, hijacks, or MOAS conflicts detected. + Routing appears stable and healthy. +``` + +``` +You: Show me all route leaks involving my prefixes in the last 7 days + +PeerCortex: Analyzing BGP updates for your prefixes over the last 7 days... + + ALERT: 2 route leak events detected + + 1. [HIGH] 2026-03-22T14:32:00Z — 203.0.113.0/24 + Leaked by AS64496 via AS64497 + Expected path: AS64496 → AS64498 → origin + Observed path: AS64496 → AS64497 → AS64499 → origin + Duration: 23 minutes + Impact: Partial traffic rerouting through unexpected path + ... +``` + +### 4. RPKI Monitoring & Compliance + +Comprehensive RPKI validation and compliance monitoring. Validates prefix-origin pairs, generates compliance reports, and identifies networks at IXs without RPKI coverage. + +**What you get:** +- Prefix-origin RPKI validation (valid/invalid/not-found) +- ROA coverage percentage for an ASN +- Compliance reports with recommendations +- IX-level RPKI coverage analysis +- Comparison with industry benchmarks +- Step-by-step remediation guidance + +**Example:** +``` +You: Generate an RPKI compliance report for AS13335 + +PeerCortex: RPKI Compliance Report — AS13335 (Cloudflare, Inc.) + + Overall Coverage: 99.8% + Total Announced Prefixes: 1,432 + RPKI Valid: 1,429 (99.8%) + RPKI Invalid: 0 (0.0%) + Not Covered: 3 (0.2%) + + Uncovered Prefixes: + - 198.51.100.0/24 (recently announced, ROA pending) + - 2001:db8:1::/48 (experimental prefix) + - 2001:db8:2::/48 (experimental prefix) + + Recommendations: + 1. Create ROAs for the 3 uncovered prefixes + 2. All ROAs have appropriate max-length settings + 3. RPKI-invalid route filtering is recommended on all sessions + ... +``` + +``` +You: Which ASNs at AMS-IX don't have RPKI? + +PeerCortex: RPKI Coverage Analysis — AMS-IX + + Total Participants: 932 + With RPKI Coverage: 714 (76.6%) + Without RPKI: 218 (23.4%) + + Largest uncovered networks: + - AS64496 (Example ISP) — 450 prefixes, 0% RPKI coverage + - AS64497 (Example Transit) — 230 prefixes, 0% RPKI coverage + ... +``` + +### 5. Network Comparison + +Side-by-side comparison of two autonomous systems across all dimensions: size, IX presence, facility overlap, peering policy, RPKI deployment, and geographic coverage. + +**What you get:** +- Side-by-side metrics table +- Common and unique IXs +- Common and unique facilities +- Peering potential score +- AI-generated comparison narrative +- Peering opportunity identification + +**Example:** +``` +You: Compare AS13335 and AS32934 — where do they peer? + +PeerCortex: Network Comparison — AS13335 (Cloudflare) vs AS32934 (Facebook) + + Metric | AS13335 (Cloudflare) | AS32934 (Facebook) + ──────────────────────────────────────────────────────────── + Type | Content | Content + Policy | Open | Selective + IPv4 Prefixes | 1,200+ | 800+ + IPv6 Prefixes | 200+ | 150+ + IX Count | 290+ | 180+ + Facility Count | 320+ | 60+ + RPKI Coverage | 99.8% | 99.5% + + Common IXs (142): + DE-CIX Frankfurt, AMS-IX, LINX, France-IX, JPNAP, Equinix IX, + HKIX, SIX Seattle, Any2 Los Angeles, TorIX, ... + + Unique to Cloudflare (148): + NIX.CZ, BKNIX, KINX, MIX-IT, ... + + Unique to Facebook (38): + ... + + These networks peer extensively at 142 common IXs worldwide. +``` + +### 6. Report Generation + +Generate presentation-ready reports suitable for NANOG, RIPE, DENOG, or internal meetings. Multiple report types in Markdown, JSON, or plain text. + +**Report types:** +- **Peering Readiness** — Evaluates an ASN's readiness for peering +- **RPKI Compliance** — Full RPKI deployment status with remediation steps +- **Network Comparison** — Detailed two-network comparison +- **BGP Health** — Routing health assessment with anomaly history +- **IX Analysis** — Internet Exchange participation analysis + +**Example:** +``` +You: Generate a peering readiness report for AS13335 + +PeerCortex: # Peering Readiness Report — AS13335 (Cloudflare, Inc.) + + ## Executive Summary + AS13335 demonstrates excellent peering readiness with global IX presence, + open peering policy, and near-complete RPKI coverage... + + ## Key Findings + - Present at 290+ IXs across 6 continents + - Open peering policy with clear documentation + - 99.8% RPKI coverage + - Active PeeringDB profile with up-to-date contact info + ... +``` + +--- + +## MCP Server Tools + +PeerCortex exposes six tools via the Model Context Protocol: + +| Tool | Description | Primary Data Sources | +|------|-------------|---------------------| +| `lookup` | ASN, prefix, and IX lookups with unified results | PeeringDB, RIPE Stat, bgp.he.net, IRR, RPKI | +| `peering` | Peering partner discovery and match scoring | PeeringDB, Ollama | +| `bgp` | BGP path analysis and anomaly detection | RIPE Stat, Route Views, bgp.he.net | +| `rpki` | RPKI validation and compliance monitoring | Routinator, RIPE RPKI Validator | +| `compare` | Side-by-side network comparison | PeeringDB, RIPE Stat, RPKI | +| `report` | Generate comprehensive analysis reports | All sources + Ollama | +| `measure_rtt` | RTT measurement via RIPE Atlas probes | RIPE Atlas | +| `traceroute` | Traceroute with ASN annotation and IXP detection | RIPE Atlas, RIPE Stat | +| `upstream_analysis` | Identify and evaluate upstream transit providers | CAIDA, bgp.he.net, RIPE Stat | +| `transit_diversity` | Assess redundancy and single points of failure | CAIDA, Route Views | +| `peering_vs_transit` | Cost/latency comparison of peering vs. transit | PeeringDB, RIPE Stat | +| `as_graph` | AS-level topology graph with relationship types | CAIDA, bgproutes.io | +| `submarine_cables` | Submarine cable lookup by region or landing point | TeleGeography, PeeringDB | +| `facility_analysis` | Colocation presence and interconnection opportunities | PeeringDB | +| `ix_traffic` | IX traffic statistics and historical trends | DE-CIX, AMS-IX, LINX | +| `ix_comparison` | Side-by-side comparison of multiple IXes | DE-CIX, AMS-IX, LINX | +| `port_utilization` | Port utilization analysis with upgrade recommendations | PeeringDB, IX APIs | +| `hijack_detection` | Detect BGP hijacks via RPKI ROV and MOAS analysis | bgproutes.io, RIPE Stat | +| `route_leak_detection_aspa` | ASPA-based route leak detection | bgproutes.io | +| `bogon_check` | Bogon prefix and bogon ASN detection | RIPE Stat, IANA | +| `blacklist_check` | IP/prefix/ASN blacklist and reputation checks | Spamhaus, Team Cymru | +| `reverse_dns` | Batch reverse DNS with FCrDNS verification | Cloudflare DoH | +| `delegation_check` | DNS delegation and DNSSEC validation | Cloudflare DoH | +| `whois_lookup` | Structured WHOIS for IPs, ASNs, and domains | RIPE DB, WHOIS | +| `atlas_create_measurement` | Create RIPE Atlas measurements | RIPE Atlas | +| `atlas_get_results` | Retrieve and summarize measurement results | RIPE Atlas | +| `atlas_search_probes` | Search probes by ASN, country, prefix, or anchor | RIPE Atlas | + +Each tool accepts structured input validated by Zod schemas and returns typed JSON responses. + +--- + +## Claude Code Integration + +Add PeerCortex to your Claude Code configuration: + +```json +{ + "mcpServers": { + "peercortex": { + "command": "node", + "args": ["/path/to/peercortex/dist/mcp-server/index.js"], + "env": { + "OLLAMA_BASE_URL": "http://localhost:11434", + "OLLAMA_MODEL": "llama3.1" + } + } + } +} +``` + +### Example Conversations + +Once configured, you can interact with PeerCortex naturally through Claude Code: + +``` +You: Give me the full picture for AS13335 + +Claude: I'll look up comprehensive information for AS13335 using PeerCortex... + +[Calls lookup tool with asn=13335] + +Here's the complete profile for AS13335 (Cloudflare, Inc.): + +Network Overview: + - Name: Cloudflare, Inc. + - Type: Content + - Peering Policy: Open + - Scope: Global + - Website: cloudflare.com + ... +``` + +``` +You: Find peering partners for AS13335 at DE-CIX with open policy + +Claude: Let me search for networks at DE-CIX with open peering policy +that would be good matches for Cloudflare... + +[Calls peering tool with asn=13335, ix="DE-CIX Frankfurt", policy="open"] + +I found 47 potential peering partners at DE-CIX Frankfurt. Here are the +top matches ranked by compatibility... +``` + +``` +You: Are there any BGP anomalies for 185.1.0.0/24 right now? + +Claude: I'll analyze the current BGP routing state for that prefix... + +[Calls bgp tool with resource="185.1.0.0/24", include_anomalies=true] + +Good news — no anomalies detected for 185.1.0.0/24. The prefix is +currently announced by AS47541 with stable routing... +``` + +``` +You: Compare AS13335 and AS32934 — where do they peer? + +Claude: Let me pull up a side-by-side comparison of Cloudflare and Facebook... + +[Calls compare tool with asn1=13335, asn2=32934] + +Here's a detailed comparison of the two networks. They peer at +142 common Internet Exchanges worldwide... +``` + +``` +You: Generate an RPKI compliance report for AS13335 + +Claude: I'll generate a comprehensive RPKI compliance report... + +[Calls rpki tool with asn=13335, include_recommendations=true] + +Here's the RPKI compliance report for Cloudflare. They have 99.8% +coverage with only 3 uncovered prefixes... +``` + +``` +You: Which ASNs at AMS-IX don't have RPKI? + +Claude: Let me analyze RPKI coverage across AMS-IX participants... + +[Calls rpki tool for IX coverage analysis] + +Out of 932 AMS-IX participants, 218 (23.4%) don't have RPKI +coverage for their announced prefixes... +``` + +``` +You: Show me all route leaks involving my prefixes in the last 7 days + +Claude: I'll scan BGP update data for route leak events... + +[Calls bgp tool with anomaly detection] + +I found 2 route leak events in the last 7 days affecting your prefixes... +``` + +``` +You: Draft a peering request email to AS714 for DE-CIX Frankfurt + +Claude: I'll draft a professional peering request to Apple... + +[Calls peering tool to generate email draft] + +Here's a ready-to-send peering request email to Apple's peering team +for establishing peering at DE-CIX Frankfurt... +``` + +``` +You: Trace the path from AS32934 (Meta) to Cloudflare (AS13335) and show latency + +Claude: I'll create a RIPE Atlas traceroute from Meta's network to Cloudflare... + +[Calls traceroute tool with target="1.1.1.1", sourceAsn=32934] + +The traceroute from AS32934 to AS13335 shows 8 hops with an average +RTT of 4.2ms. The path crosses DE-CIX Frankfurt at hop 4, where both +networks are present. No IXP-level congestion detected... +``` + +``` +You: Detect route leaks using ASPA validation for prefixes of AS13335 + +Claude: I'll check all Cloudflare prefixes for ASPA validation failures... + +[Calls route_leak_detection_aspa tool with asn=13335] + +Checked 1,432 prefixes for AS13335 (Cloudflare). No active route leaks +detected via ASPA validation. AS13335 has ASPA objects registered for +all upstream providers. 2 historical leak events found from the past +90 days, both resolved within 15 minutes... +``` + +``` +You: Show IX traffic trends at DE-CIX Frankfurt for the last 12 months + +Claude: I'll fetch DE-CIX Frankfurt traffic statistics with monthly granularity... + +[Calls ix_traffic tool with ix="de-cix-frankfurt", period="12m", granularity="monthly"] + +DE-CIX Frankfurt traffic over the last 12 months: + Current Peak: 14.2 Tbps + Average: 8.7 Tbps + Year-over-Year Growth: +18.4% + Connected Networks: 1,100+ + Monthly trend shows consistent growth with seasonal peaks in Q4... +``` + +``` +You: What would change if AS32934 (Meta) peered directly with AS13335 (Cloudflare) +instead of using transit? + +Claude: Let me analyze the current path and potential direct peering benefits... + +[Calls peering_vs_transit tool with sourceAsn=32934, targetAsn=13335] + +Current path: AS32934 → AS174 (Cogent) → AS13335 (3 hops, ~12ms RTT) +With direct peering at DE-CIX Frankfurt: AS32934 → AS13335 (1 hop, ~2ms RTT) + +Both networks are present at 142 common IXes and 28 common facilities. +Estimated RTT reduction: 10ms. Direct peering is strongly recommended +given the extensive IX overlap and open peering policies... +``` + +--- + +## Data Sources + +| Source | URL | Data Provided | Update Frequency | +|--------|-----|---------------|------------------| +| **PeeringDB** | [peeringdb.com](https://www.peeringdb.com/) | Network info, IXs, facilities, contacts | User-maintained (near real-time) | +| **RIPE Stat** | [stat.ripe.net](https://stat.ripe.net/) | BGP state, prefixes, visibility, RPKI | Real-time (RIS collectors) | +| **bgp.he.net** | [bgp.he.net](https://bgp.he.net/) | Peers, upstreams, downstreams, prefixes | Multiple times daily | +| **Route Views** | [routeviews.org](https://www.routeviews.org/) | Global routing table, path diversity | Real-time (via RIPE Stat) | +| **IRR** | [rest.db.ripe.net](https://rest.db.ripe.net/) | Route objects, as-sets, WHOIS | Near real-time | +| **RPKI** | Local Routinator / RIPE RPKI | ROA validation, VRP list | Every ~10 minutes | +| **bgproutes.io** | [bgproutes.io](https://bgproutes.io/) | RIB entries, BGP updates, AS topology, RPKI ROV + ASPA validation | Real-time | +| **RIPE Atlas** | [atlas.ripe.net](https://atlas.ripe.net/) | Ping, traceroute, DNS, SSL measurements from global probes | On-demand | +| **CAIDA AS Rank** | [asrank.caida.org](https://asrank.caida.org/) | AS relationships, customer cones, rankings | Periodic | +| **IX Traffic** | DE-CIX, AMS-IX, LINX public APIs | IX traffic statistics and trends | Near real-time | +| **DNS-over-HTTPS** | Cloudflare/Google DoH | rDNS, delegation, DNSSEC verification | Real-time | + +All data is fetched directly from authoritative sources. PeerCortex caches responses locally in SQLite to reduce API calls and improve response times. + +--- + +## Feature Comparison + +How PeerCortex compares to existing tools: + +| Feature | PeerCortex | bgpq4 | peeringdb-py | ripestat-cli | bgpstream | +|---------|:----------:|:-----:|:------------:|:------------:|:---------:| +| ASN Lookup (unified) | Yes | - | Partial | Partial | - | +| Peering Discovery | AI-ranked | - | Basic | - | - | +| BGP Analysis | Yes | - | - | Yes | Yes | +| Anomaly Detection | AI-powered | - | - | Partial | Yes | +| RPKI Monitoring | Yes | - | - | Partial | - | +| Network Comparison | Yes | - | - | - | - | +| Report Generation | AI-powered | - | - | - | - | +| MCP Integration | Native | - | - | - | - | +| Local AI | Ollama | - | - | - | - | +| Multi-source | 6 sources | 1 (IRR) | 1 (PDB) | 1 (RIPE) | 1 (RIS) | +| Self-hosted | Yes | Yes | Yes | Yes | Yes | +| No cloud dependency | Yes | Yes | Yes | Yes | Yes | + +PeerCortex is not a replacement for these excellent tools — it complements them by providing a unified, AI-enhanced interface for the most common network intelligence tasks. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ MCP Client (Claude Code) │ +└──────────────────────────┬───────────────────────────────────────┘ + │ stdio / SSE +┌──────────────────────────▼───────────────────────────────────────┐ +│ PeerCortex MCP Server │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────┐ ┌──────┐ ┌────────┐ ┌───────┐ │ +│ │ lookup │ │ peering │ │ bgp │ │ rpki │ │compare │ │report │ │ +│ └────┬────┘ └────┬────┘ └──┬──┘ └──┬───┘ └───┬────┘ └───┬───┘ │ +│ └───────────┴─────────┴───────┴──────────┴──────────┘ │ +│ │ │ +│ ┌───────────────────────────▼──────────────────────────────────┐│ +│ │ Source Aggregation Layer ││ +│ │ PeeringDB · RIPE Stat · bgp.he.net · Route Views · IRR · RPKI ││ +│ └──────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ SQLite Cache │ │ Ollama (Local AI) │ │ +│ │ Response caching │ │ Analysis & report generation │ │ +│ └─────────────────────┘ └──────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +For detailed architecture documentation, see [docs/architecture.md](docs/architecture.md). + +--- + +## Quick Start + +### Option 1: Docker (Recommended) + +```bash +# Clone the repository +git clone https://github.com/peercortex/peercortex.git +cd peercortex + +# Copy environment configuration +cp .env.example .env + +# Start PeerCortex + Ollama +docker compose up -d + +# Pull the AI model +docker exec peercortex-ollama ollama pull llama3.1 + +# Verify it's running +docker logs peercortex +``` + +### Option 2: Local Installation + +```bash +# Prerequisites: Node.js 20+, Ollama installed + +# Clone and install +git clone https://github.com/peercortex/peercortex.git +cd peercortex +npm install + +# Configure +cp .env.example .env +# Edit .env with your settings + +# Build and start +npm run build +npm start +``` + +### Option 3: npx (One-liner) + +```bash +# Run directly without installing +OLLAMA_BASE_URL=http://localhost:11434 npx peercortex +``` + +### Configure Claude Code + +Add to your Claude Code MCP configuration (`~/.claude.json` or project `.claude.json`): + +```json +{ + "mcpServers": { + "peercortex": { + "command": "node", + "args": ["/path/to/peercortex/dist/mcp-server/index.js"], + "env": { + "OLLAMA_BASE_URL": "http://localhost:11434", + "OLLAMA_MODEL": "llama3.1" + } + } + } +} +``` + +For detailed setup instructions, see [docs/setup.md](docs/setup.md). + +--- + +## Configuration + +All configuration is done via environment variables. Copy `.env.example` to `.env` and customize: + +| Variable | Default | Description | +|----------|---------|-------------| +| `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama API endpoint | +| `OLLAMA_MODEL` | `llama3.1` | LLM model for AI analysis | +| `PEERINGDB_API_KEY` | _(empty)_ | Optional PeeringDB API key for higher rate limits | +| `RIPE_STAT_SOURCE_APP` | `peercortex` | RIPE Stat source app identifier | +| `ROUTINATOR_URL` | `http://localhost:8323` | Local RPKI validator URL | +| `RIPE_RPKI_VALIDATOR_URL` | `https://rpki-validator.ripe.net/api/v1` | RIPE RPKI fallback | +| `CACHE_DB_PATH` | `./peercortex-cache.db` | SQLite cache file location | +| `CACHE_TTL_SECONDS` | `3600` | Cache time-to-live (1 hour) | +| `MCP_TRANSPORT` | `stdio` | MCP transport: `stdio` or `sse` | +| `MCP_PORT` | `3100` | Port for SSE transport | +| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | + +### Recommended Ollama Models + +| Model | Size | Best For | +|-------|------|----------| +| `llama3.1` | 8B | General analysis (recommended default) | +| `llama3.1:70b` | 70B | Deep analysis (requires 40GB+ RAM) | +| `mistral` | 7B | Fast analysis, good quality | +| `codellama` | 7B | Technical report generation | +| `mixtral` | 8x7B | Complex multi-source analysis | + +--- + +## Privacy & Security + +PeerCortex is designed for privacy-conscious network operators: + +- **100% Local AI**: All inference runs on your machine via Ollama. No data is sent to OpenAI, Anthropic, Google, or any other cloud AI service. +- **No Telemetry**: PeerCortex does not collect or transmit any usage data. +- **No Account Required**: Works without any API keys (PeeringDB key is optional for higher rate limits). +- **Local Cache**: All cached data is stored in a local SQLite database on your machine. +- **Open Source**: Full source code available for audit. MIT license. + +**Data flow:** +1. Your query goes from Claude Code to the local PeerCortex MCP server +2. PeerCortex queries public APIs (PeeringDB, RIPE Stat, etc.) for factual data +3. Ollama (running locally) analyzes the data +4. Results are returned to Claude Code + +At no point does your query content, network topology, or analysis results leave your machine for AI processing. + +--- + +## ASPA Intelligence + +### What is ASPA? + +**Autonomous System Provider Authorization (ASPA)** is an RPKI-based mechanism defined in [RFC 9582](https://www.rfc-editor.org/rfc/rfc9582) that enables detection and prevention of route leaks. While RPKI ROA (Route Origin Authorization) validates who is authorized to **originate** a prefix, ASPA validates the **path** a route takes through the Internet. + +Each AS publishes an ASPA object declaring its authorized upstream providers. When a BGP router receives a route, it can walk the AS path and verify that each customer-to-provider hop is authorized. Unauthorized hops indicate a route leak — a common and damaging class of BGP incidents. + +**Why it matters:** + +- Route leaks caused by misconfigured BGP sessions are responsible for major Internet outages every year +- ASPA provides cryptographic proof of provider relationships, complementing ROA validation +- Together, ROA + ASPA cover the two most important BGP security gaps: origin validation and path validation +- ASPA is particularly effective against lateral ISS-ISS leaks and customer-to-provider leaks (RFC 7908) + +### ASPA Tools + +PeerCortex provides 6 ASPA-focused tools: + +| Tool | Description | +|------|-------------| +| `peercortex_aspa_validate` | Validate an AS path against ASPA objects (RFC 9582 Section 6 algorithm) | +| `peercortex_aspa_analyze` | Full ASPA readiness analysis — existing objects, detected providers, recommendations | +| `peercortex_aspa_generate` | Auto-generate a RIPE DB ASPA object template from BGP data | +| `peercortex_aspa_simulate` | "What-if" simulation: how many incidents would ASPA have prevented? | +| `peercortex_aspa_coverage` | ASPA adoption statistics per IXP or geographic region | +| `peercortex_aspa_leaks` | Real-time route leak detection using ASPA validation | + +### Example Conversations + +**Analyze ASPA readiness:** +``` +You: Analyze ASPA readiness for AS13335 +Claude: AS13335 (Cloudflare) currently has no registered ASPA object. Based on BGP path + analysis, the detected upstream providers are AS174 (Cogent) and AS3356 (Lumen). + Recommendation: Register an ASPA object listing these providers via the RIPE DB. +``` + +**Generate an ASPA object:** +``` +You: Generate an ASPA object for AS13335 +Claude: Here is a RIPE DB-ready ASPA template for AS13335: + + aut-num: AS13335 + upstream: AS174 # Cogent (confidence: 95%) + upstream: AS3356 # Lumen (confidence: 90%) + mnt-by: MNT-CLOUDFLARE + source: RIPE + + Submit at: https://apps.db.ripe.net/db-web-ui/webupdates +``` + +**What-if simulation:** +``` +You: What would ASPA have prevented in the last 30 days? +Claude: Analyzing 15 BGP incidents from the last 30 days... + ASPA would have prevented 11 of 15 incidents (73% prevention rate). + - Route leaks: 8/10 prevented + - Hijacks: 2/3 prevented + - Misconfigurations: 1/2 prevented +``` + +**IXP adoption:** +``` +You: Show ASPA adoption at DE-CIX Frankfurt +Claude: DE-CIX Frankfurt ASPA Coverage: + - Total participants: 950 + - With ASPA objects: 85 (8.9%) + - Top adopters: AS13335 (Cloudflare), AS32934 (Meta), AS15169 (Google) +``` + +**Leak detection:** +``` +You: Detect route leaks using ASPA for 1.1.1.0/24 +Claude: Analyzing last 24 hours of BGP updates for 1.1.1.0/24... + Found 3 route leak events: + - CRITICAL: AS64501 leaked 1.1.1.0/24 to AS3356 (17:42 UTC) + - HIGH: AS64502 re-originated 1.1.1.0/24 (03:15 UTC) + - MEDIUM: AS64503 lateral leak to AS6939 (21:08 UTC) +``` + +**Deployment simulation:** +``` +You: Simulate: what if all DE-CIX members deployed ASPA? +Claude: If all 950 DE-CIX Frankfurt members deployed ASPA: + - Route leak prevention rate would increase from 8.9% to ~92% + - An estimated 340 route leak incidents per year would be prevented + - Critical incidents (affecting /8 or larger) would drop by 95% +``` + +--- + +## Roadmap + +### v0.1 — Foundation (Current) +- [x] Project structure and type definitions +- [x] MCP server with 6 tool definitions +- [x] PeeringDB API client +- [x] RIPE Stat API client +- [x] bgp.he.net scraper skeleton +- [x] Route Views / RIPE RIS client +- [x] IRR / WHOIS client +- [x] RPKI validator client +- [x] Ollama AI integration +- [x] SQLite cache layer +- [ ] Complete tool implementations +- [ ] Unit and integration tests + +### v0.2 — Core Features +- [ ] Full ASN lookup with all sources +- [ ] Peering partner scoring algorithm +- [ ] BGP anomaly detection engine +- [ ] RPKI compliance reporting +- [ ] Network comparison logic +- [ ] Report templates (Markdown, JSON) + +### v0.3 — Intelligence +- [ ] AI-powered anomaly classification +- [ ] Peering request email generation +- [ ] Historical trend analysis +- [ ] Route leak correlation +- [ ] RPKI deployment tracking over time + +### v0.4 — Production +- [ ] SSE transport support +- [ ] Webhook alerts for anomalies +- [ ] Prometheus metrics endpoint +- [ ] Comprehensive test suite (80%+ coverage) +- [ ] Performance optimization +- [ ] npm package publishing + +### Future +- [x] bgproutes.io integration (ASPA validation support) +- [ ] BGP community analysis +- [ ] Traffic estimation from prefix visibility +- [ ] Peering ROI calculator +- [ ] Multi-language report generation +- [ ] Web dashboard (optional) +- [ ] Slack/Discord bot integration +- [ ] PeeringDB write API (submit peering requests) + +--- + +## Contributing + +Contributions are welcome! PeerCortex is built by network engineers, for network engineers. + +### Getting Started + +```bash +# Fork and clone +git clone https://github.com/YOUR_USERNAME/peercortex.git +cd peercortex + +# Install dependencies +npm install + +# Run in development mode (auto-reload) +npm run dev + +# Run tests +npm test + +# Type checking +npm run typecheck + +# Linting +npm run lint +``` + +### Contribution Guidelines + +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feat/amazing-feature`) +3. **Write tests** for your changes +4. **Ensure** all tests pass and types check +5. **Commit** using conventional commits (`feat:`, `fix:`, `docs:`, etc.) +6. **Push** your branch and open a Pull Request + +### Areas Where Help is Needed + +- **bgp.he.net scraper**: Improve HTML parsing for all data tabs +- **Anomaly detection**: Implement route leak and hijack detection algorithms +- **RPKI compliance**: Complete the compliance reporting logic +- **Test coverage**: Unit and integration tests for all modules +- **Documentation**: Examples, tutorials, and API documentation +- **Performance**: Optimize parallel data source queries + +--- + +## FAQ + +**Q: Do I need an Ollama instance to use PeerCortex?** +A: Ollama is recommended for AI-powered features (analysis, ranking, report generation) but not strictly required. The data lookup tools (lookup, bgp, rpki) work without AI — they return raw structured data that Claude Code can interpret directly. + +**Q: Which Ollama model should I use?** +A: `llama3.1` (8B) is the recommended default. It provides excellent analysis quality while running on most hardware. For deeper analysis, try `llama3.1:70b` if you have 40GB+ RAM. + +**Q: Does PeerCortex send my data to the cloud?** +A: No. All AI inference runs locally via Ollama. PeerCortex queries public APIs (PeeringDB, RIPE Stat, etc.) for factual network data, but your queries and analysis results never leave your machine. + +**Q: Can I use this without Claude Code?** +A: Yes! PeerCortex is a standard MCP server. It works with any MCP-compatible client, including Claude Desktop, custom MCP clients, or direct stdio interaction. + +**Q: How accurate is the BGP anomaly detection?** +A: PeerCortex uses data from RIPE RIS collectors and Route Views, which are the same data sources used by academic BGP monitoring systems. AI analysis adds context but all findings are based on real routing data. + +**Q: Can I use this for production monitoring?** +A: PeerCortex v0.x is designed for interactive querying and analysis. Production monitoring with alerting is planned for v0.4+. For now, it complements (not replaces) production monitoring tools like BGPalerter. + +**Q: What about IPv6?** +A: Full IPv6 support. All tools handle both IPv4 and IPv6 prefixes, and PeeringDB data includes IPv6 IX addresses. + +**Q: How do I get a PeeringDB API key?** +A: Create an account at [peeringdb.com](https://www.peeringdb.com/), go to your profile settings, and generate an API key. It's free and gives you higher rate limits. + +**Q: Can I run PeerCortex behind a firewall?** +A: Yes, with some considerations. PeerCortex needs outbound HTTP access to PeeringDB, RIPE Stat, bgp.he.net, and optionally RIPE RPKI. If you run Routinator locally, RPKI validation works fully offline. Ollama runs entirely local. + +--- + +## Acknowledgments + +PeerCortex is built on the shoulders of these incredible projects and organizations: + +- **[PeeringDB](https://www.peeringdb.com/)** — The freely available, user-maintained database of networks. Thank you to PeeringDB Inc. and all contributors who keep peering data open and accessible. +- **[RIPE NCC](https://www.ripe.net/)** — For RIPE Stat, RIPE RIS, and the RIPE Database. Essential infrastructure for Internet measurement and analysis. +- **[Route Views](https://www.routeviews.org/)** — University of Oregon's Route Views project for global routing table collection. +- **[Ollama](https://ollama.com/)** — Making local AI accessible and easy to run. +- **[NLnet Labs](https://nlnetlabs.nl/)** — For Routinator and advancing RPKI deployment. +- **[Hurricane Electric](https://he.net/)** — For bgp.he.net, an invaluable BGP toolkit. +- **[Model Context Protocol](https://modelcontextprotocol.io/)** — Anthropic's MCP specification enabling AI tool integration. + +--- + +## Ecosystem + +### Part of the Cortex Family + +PeerCortex is part of a growing ecosystem of AI-powered MCP tools: + +| Project | Description | +|---------|-------------| +| **[PaperCortex](https://github.com/papercortex/papercortex)** | AI-powered academic paper management and research assistant | +| **PeerCortex** | AI-powered network intelligence platform (you are here) | + +Each Cortex project follows the same philosophy: **local AI, open source, privacy-first, MCP-native**. + +--- + +

+ PeerCortex — Network intelligence, unified.
+ Built with care for the network engineering community. +

+ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bcebf9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +version: "3.9" + +services: + # ── PeerCortex MCP Server ─────────────────────────────── + peercortex: + build: . + container_name: peercortex + restart: unless-stopped + environment: + - OLLAMA_BASE_URL=http://ollama:11434 + - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.1} + - PEERINGDB_API_KEY=${PEERINGDB_API_KEY:-} + - RIPE_STAT_SOURCE_APP=${RIPE_STAT_SOURCE_APP:-peercortex} + - ROUTINATOR_URL=${ROUTINATOR_URL:-http://routinator:8323} + - CACHE_DB_PATH=/app/data/peercortex-cache.db + - CACHE_TTL_SECONDS=${CACHE_TTL_SECONDS:-3600} + - MCP_TRANSPORT=${MCP_TRANSPORT:-stdio} + - MCP_PORT=3100 + - LOG_LEVEL=${LOG_LEVEL:-info} + volumes: + - peercortex-data:/app/data + ports: + - "${MCP_PORT:-3100}:3100" + depends_on: + - ollama + networks: + - peercortex-net + + # ── Ollama (Local AI) ────────────────────────────────── + ollama: + image: ollama/ollama:latest + container_name: peercortex-ollama + restart: unless-stopped + volumes: + - ollama-models:/root/.ollama + ports: + - "11434:11434" + networks: + - peercortex-net + # Uncomment for GPU support: + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] + + # ── Routinator (RPKI Validator) — Optional ───────────── + # Uncomment to run a local RPKI validator + # routinator: + # image: nlnetlabs/routinator:latest + # container_name: peercortex-routinator + # restart: unless-stopped + # ports: + # - "8323:8323" + # volumes: + # - routinator-data:/home/routinator/.rpki-cache + # networks: + # - peercortex-net + +volumes: + peercortex-data: + ollama-models: + # routinator-data: + +networks: + peercortex-net: + driver: bridge diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5fdabd6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,89 @@ +# Architecture + +## System Overview + +PeerCortex is an MCP (Model Context Protocol) server that acts as a unified interface between AI assistants and multiple network intelligence data sources. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ MCP Client Layer │ +│ (Claude Code / Claude Desktop / Any MCP Client) │ +└──────────────────────────┬───────────────────────────────────────┘ + │ MCP Protocol (stdio / SSE) +┌──────────────────────────▼───────────────────────────────────────┐ +│ PeerCortex MCP Server │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────┐ ┌──────┐ ┌────────┐ ┌───────┐ │ +│ │ lookup │ │ peering │ │ bgp │ │ rpki │ │compare │ │report │ │ +│ └────┬────┘ └────┬────┘ └──┬──┘ └──┬───┘ └───┬────┘ └───┬───┘ │ +│ │ │ │ │ │ │ │ +│ ┌────▼───────────▼─────────▼───────▼──────────▼──────────▼───┐ │ +│ │ Source Aggregation Layer │ │ +│ └────┬───────┬────────┬────────┬────────┬─────────┬──────────┘ │ +│ │ │ │ │ │ │ │ +│ ┌────▼──┐┌───▼───┐┌───▼──┐┌───▼───┐┌───▼──┐┌────▼────┐ │ +│ │Peering││RIPE ││bgp. ││Route ││IRR / ││RPKI │ │ +│ │DB ││Stat ││he.net││Views ││WHOIS ││Validator│ │ +│ └───────┘└───────┘└──────┘└───────┘└──────┘└─────────┘ │ +│ │ +│ ┌─────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ SQLite Cache │ │ Ollama (Local AI) │ │ +│ │ (API Responses) │ │ (Analysis & Report Generation) │ │ +│ └─────────────────────┘ └──────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Component Details + +### MCP Server (`src/mcp-server/`) + +The server exposes 6 tools via the Model Context Protocol: + +| Tool | Purpose | Primary Sources | +|------|---------|----------------| +| `lookup` | ASN / Prefix / IX lookups | PeeringDB, RIPE Stat, bgp.he.net, IRR, RPKI | +| `peering` | Peering partner discovery | PeeringDB, Ollama | +| `bgp` | BGP analysis & anomaly detection | RIPE Stat, Route Views, bgp.he.net | +| `rpki` | RPKI validation & compliance | Routinator, RIPE RPKI, RIPE Stat | +| `compare` | Network comparison | PeeringDB, RIPE Stat, RPKI | +| `report` | Report generation | All sources + Ollama | + +### Data Sources (`src/sources/`) + +Each source module implements a consistent client interface: + +- **PeeringDB** (`peeringdb.ts`): RESTful API v2 with optional API key auth +- **RIPE Stat** (`ripe-stat.ts`): Data calls API for routing and resource info +- **bgp.he.net** (`bgp-he.ts`): HTML scraping (no official API available) +- **Route Views** (`route-views.ts`): Via RIPE Stat RIS data calls +- **IRR** (`irr.ts`): RIPE DB REST API + WHOIS protocol +- **RPKI** (`rpki.ts`): Routinator API with RIPE RPKI fallback + +### AI Layer (`src/ai/`) + +- **Ollama Client**: Interfaces with local Ollama instance +- **Prompt Templates**: Specialized prompts for each analysis type +- All inference runs locally — no data leaves the machine + +### Cache Layer (`src/cache/`) + +- SQLite-backed with WAL mode for performance +- TTL-based expiration per entry +- Source-level invalidation support +- Reduces API calls and improves response times + +## Data Flow + +1. MCP client sends a tool invocation (e.g., `lookup` with ASN=13335) +2. Tool handler validates input using Zod schemas +3. Cache is checked for fresh data +4. Source clients query external APIs in parallel +5. Results are merged, cached, and optionally analyzed by Ollama +6. Structured response is returned via MCP protocol + +## Security Model + +- No data is sent to cloud AI services (Ollama runs locally) +- API keys are stored in environment variables, never in code +- PeeringDB API key is optional (works without auth at lower rate limits) +- Cache database is local SQLite — no external database dependencies diff --git a/docs/assets/demo.svg b/docs/assets/demo.svg new file mode 100644 index 0000000..bbe532c --- /dev/null +++ b/docs/assets/demo.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + PeerCortex v1.0.0 + + + + + + + $ peercortex lookup AS13335 + + + + ┌─ Cloudflare, Inc. ────────────────────────────────────── + + + ASN: AS13335 IRR: AS-CLOUDFLARE + + + Type: CDN / Cloud Policy: Open + + + Prefixes: 1,847 IPv4 289 IPv6 + + + IXPs: 312 exchanges Facilities: 387 + + + RPKI: 98.7% signed ROAs: 2,136 + + + └──────────────────────────────────────────────────────── + + + + + $ peercortex peers find --asn 207613 --ix "DE-CIX Frankfurt" + + + + 🤝 Recommended Peering Partners at DE-CIX Frankfurt: + + + #1 AS13335 Cloudflare Score: 97 Open Policy 312 shared IXPs + + + #2 AS32934 Meta Score: 94 Open Policy 187 shared IXPs + + + #3 AS15169 Google Score: 91 Selective 245 shared IXPs + + + #4 AS16509 Amazon Score: 88 Selective 156 shared IXPs + + + #5 AS714 Apple Score: 85 Open Policy 98 shared IXPs + + + 5 matches in 1.2s (filtered from 1,847 DE-CIX members) + + + + + $ peercortex rpki check --asn 207613 + + + + 🛡️ RPKI Compliance Report — AS207613 + + + ROA Coverage: 47/52 prefixes (90.4%) + + + Valid: 45 (86.5%) + + + Invalid: 2 (3.8%) ⚠ Action needed + + + Not Found: 5 (9.6%) + + + ⚠ INVALID: 185.1.0.0/22 — ROA maxLength mismatch + + + ⚠ INVALID: 2001:db8::/32 — Origin AS mismatch + + + + + $ peercortex bgp anomalies --asn 207613 --hours 24 + + + + 🔍 BGP Anomalies (last 24h): + + + ✅ No route leaks detected + + + ✅ No hijacks detected + + + ⚠ 1 path change: 185.1.0.0/24 — new upstream AS174 (was AS3356) + + + Overall: HEALTHY + + + + \ No newline at end of file diff --git a/docs/assets/peercortex-logo.svg b/docs/assets/peercortex-logo.svg new file mode 100644 index 0000000..97c377d --- /dev/null +++ b/docs/assets/peercortex-logo.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/data-sources.md b/docs/data-sources.md new file mode 100644 index 0000000..6ebdd20 --- /dev/null +++ b/docs/data-sources.md @@ -0,0 +1,95 @@ +# Data Sources + +PeerCortex aggregates network intelligence from six data sources. Each source provides unique data that is combined to create a comprehensive picture. + +## Source Overview + +| Source | Data Provided | API Type | Auth Required | Rate Limits | +|--------|--------------|----------|---------------|-------------| +| PeeringDB | Network info, IXs, facilities, contacts | REST API v2 | Optional (API key) | 60 req/min (anonymous), higher with key | +| RIPE Stat | BGP state, prefixes, visibility, RPKI | REST API | No (source app ID recommended) | Fair use policy | +| bgp.he.net | Peers, upstreams, downstreams, prefixes | HTML scraping | No | Be respectful | +| Route Views | Global routing table, path diversity | Via RIPE Stat | No | Via RIPE Stat limits | +| IRR (RIPE DB) | Route objects, as-sets, WHOIS | REST + WHOIS | No | Fair use policy | +| RPKI | ROA validation, VRP list | REST API | No | Depends on validator | + +## PeeringDB + +**URL**: https://www.peeringdb.com/ + +The freely available, user-maintained database of networks. Primary source for: + +- Network metadata (name, ASN, type, scope) +- Peering policy information +- Internet Exchange participation (with connection speeds) +- Facility/colocation presence +- Points of contact for peering + +**API Documentation**: https://www.peeringdb.com/apidocs/ + +## RIPE Stat + +**URL**: https://stat.ripe.net/ + +Comprehensive Internet resource analysis from RIPE NCC. Provides: + +- AS overview and holder information +- Announced prefix lists +- BGP state from RIPE RIS collectors +- BGP update history +- Looking glass data +- RPKI validation +- Prefix visibility across collectors + +**API Documentation**: https://stat.ripe.net/docs/02.data-api/ + +## bgp.he.net + +**URL**: https://bgp.he.net/ + +Hurricane Electric's BGP Toolkit. Provides through web scraping: + +- Peer lists (v4/v6) +- Upstream and downstream relationships +- Originated prefix lists +- IX participation details +- WHOIS information + +**Note**: No official API. PeerCortex uses respectful HTML scraping. + +## Route Views / RIPE RIS + +**URL**: https://www.routeviews.org/ and https://ris.ripe.net/ + +Global routing data collected from BGP vantage points worldwide: + +- Full routing table snapshots +- BGP update streams +- Path diversity analysis +- Prefix visibility reports + +Accessed via RIPE Stat API data calls. + +## IRR Databases + +**Primary**: https://rest.db.ripe.net/ (RIPE DB REST API) + +Internet Routing Registry data from RIPE, RADB, and others: + +- Route and route6 objects +- AS-set definitions and expansion +- Aut-num objects +- Maintainer information +- WHOIS records + +## RPKI Validators + +**Routinator** (local): https://routinator.docs.nlnetlabs.nl/ +**RIPE RPKI** (remote): https://rpki-validator.ripe.net/ + +Route Origin Authorization validation: + +- Prefix-origin pair validation (valid/invalid/not-found) +- ROA listings per ASN +- Validated ROA Payload (VRP) list +- Trust Anchor information diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..b1a33e1 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,108 @@ +# Setup Guide + +## Prerequisites + +- **Node.js** 20+ (LTS recommended) +- **Ollama** installed and running locally +- **Docker** (optional, for containerized deployment) + +## Quick Start + +### 1. Install Ollama + +```bash +# macOS +brew install ollama + +# Linux +curl -fsSL https://ollama.com/install.sh | sh + +# Pull a recommended model +ollama pull llama3.1 +``` + +### 2. Install PeerCortex + +```bash +# Clone the repository +git clone https://github.com/peercortex/peercortex.git +cd peercortex + +# Install dependencies +npm install + +# Copy environment configuration +cp .env.example .env + +# Build +npm run build + +# Start the MCP server +npm start +``` + +### 3. Configure Claude Code + +Add PeerCortex to your Claude Code MCP configuration: + +```json +{ + "mcpServers": { + "peercortex": { + "command": "node", + "args": ["/path/to/peercortex/dist/mcp-server/index.js"], + "env": { + "OLLAMA_BASE_URL": "http://localhost:11434", + "OLLAMA_MODEL": "llama3.1" + } + } + } +} +``` + +### 4. Docker Setup (Alternative) + +```bash +# Start PeerCortex with Ollama +docker compose up -d + +# Pull the AI model inside the Ollama container +docker exec peercortex-ollama ollama pull llama3.1 +``` + +## Configuration Reference + +See `.env.example` for all available configuration options. + +| Variable | Default | Description | +|----------|---------|-------------| +| `OLLAMA_BASE_URL` | `http://localhost:11434` | Ollama API endpoint | +| `OLLAMA_MODEL` | `llama3.1` | LLM model for analysis | +| `PEERINGDB_API_KEY` | _(empty)_ | Optional PeeringDB API key | +| `RIPE_STAT_SOURCE_APP` | `peercortex` | RIPE Stat source app identifier | +| `ROUTINATOR_URL` | `http://localhost:8323` | Routinator RPKI validator URL | +| `CACHE_DB_PATH` | `./peercortex-cache.db` | SQLite cache file path | +| `CACHE_TTL_SECONDS` | `3600` | Default cache TTL | +| `MCP_TRANSPORT` | `stdio` | Transport protocol (stdio/sse) | +| `LOG_LEVEL` | `info` | Logging level | + +## Optional: PeeringDB API Key + +PeerCortex works without a PeeringDB API key, but you'll hit rate limits faster. +To get a free API key: + +1. Create an account at [peeringdb.com](https://www.peeringdb.com/) +2. Go to your profile settings +3. Generate an API key +4. Add it to your `.env` file + +## Optional: Local RPKI Validator + +For faster RPKI validation, run Routinator locally: + +```bash +# Via Docker +docker run -d --name routinator -p 8323:8323 nlnetlabs/routinator + +# Or uncomment the routinator service in docker-compose.yml +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..d3db010 --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "name": "peercortex", + "version": "0.1.0", + "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", + "bin": { + "peercortex": "dist/mcp-server/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx watch src/mcp-server/index.ts", + "start": "node dist/mcp-server/index.js", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "clean": "rm -rf dist/", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "mcp", + "mcp-server", + "peeringdb", + "bgp", + "rpki", + "ripe-stat", + "network-intelligence", + "peering", + "asn-lookup", + "prefix-lookup", + "internet-exchange", + "route-leak-detection", + "bgp-hijack-detection", + "bgp-monitoring", + "bgp-analysis", + "peering-automation", + "rpki-monitoring", + "rpki-compliance", + "noc-tools", + "network-operator", + "ollama", + "self-hosted", + "local-ai", + "claude-code", + "irr-query", + "route-views", + "bgp-anomaly" + ], + "author": "PeerCortex Contributors", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/peercortex/peercortex.git" + }, + "bugs": { + "url": "https://github.com/peercortex/peercortex/issues" + }, + "homepage": "https://github.com/peercortex/peercortex#readme", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "better-sqlite3": "^11.7.0", + "cheerio": "^1.0.0", + "node-whois": "^2.1.3", + "ollama": "^0.5.12", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.0", + "@types/node-whois": "^2.0.3", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "@vitest/coverage-v8": "^2.1.0", + "eslint": "^9.16.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } +} diff --git a/src/ai/ollama.ts b/src/ai/ollama.ts new file mode 100644 index 0000000..aba195c --- /dev/null +++ b/src/ai/ollama.ts @@ -0,0 +1,205 @@ +/** + * @module ai/ollama + * Ollama client for local AI-powered network analysis. + * + * Uses Ollama to run LLMs locally for analyzing BGP data, generating + * peering recommendations, creating reports, and detecting anomalies. + * No data is sent to any cloud service. + * + * @see https://ollama.com/ + */ + +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +interface OllamaClientConfig { + readonly baseUrl?: string; + readonly model?: string; + readonly timeoutMs?: number; +} + +/** Ollama generation request */ +interface OllamaGenerateRequest { + readonly model: string; + readonly prompt: string; + readonly system?: string; + readonly temperature?: number; + readonly top_p?: number; + readonly stream?: boolean; +} + +/** Ollama generation response */ +interface OllamaGenerateResponse { + readonly model: string; + readonly response: string; + readonly done: boolean; + readonly context?: ReadonlyArray; + readonly total_duration?: number; + readonly load_duration?: number; + readonly prompt_eval_count?: number; + readonly prompt_eval_duration?: number; + readonly eval_count?: number; + readonly eval_duration?: number; +} + +/** Ollama model info */ +interface OllamaModelInfo { + readonly name: string; + readonly size: number; + readonly digest: string; + readonly details: { + readonly format: string; + readonly family: string; + readonly parameter_size: string; + readonly quantization_level: string; + }; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * Ollama client for local AI analysis. + * + * All inference runs locally on your machine via Ollama. + * No network data is sent to any external AI service. + * + * @example + * ```typescript + * const ai = createOllamaClient({ model: "llama3.1" }); + * const analysis = await ai.analyze("Analyze this BGP path: ...", "bgp_analysis"); + * ``` + */ +export interface OllamaClient { + /** Generate a response from the local LLM */ + generate(prompt: string, systemPrompt?: string): Promise; + + /** Analyze network data with a specific analysis type */ + analyze( + data: string, + analysisType: + | "bgp_analysis" + | "peering_recommendation" + | "anomaly_detection" + | "rpki_assessment" + | "network_comparison" + | "report_generation" + ): Promise; + + /** Check if Ollama is running and the model is available */ + healthCheck(): Promise; + + /** List available models */ + listModels(): Promise>; + + /** Get the currently configured model name */ + getModel(): string; +} + +/** + * Create a new Ollama client for local AI inference. + * + * @param config - Client configuration + * @returns A configured Ollama client instance + */ +export function createOllamaClient( + config: OllamaClientConfig = {} +): OllamaClient { + const baseUrl = config.baseUrl ?? "http://localhost:11434"; + const model = config.model ?? "llama3.1"; + const timeoutMs = config.timeoutMs ?? 120000; // LLM inference can be slow + + /** + * Make a request to the Ollama API. + */ + async function ollamaRequest( + path: string, + body?: unknown + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${baseUrl}${path}`, { + method: body ? "POST" : "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `Ollama API error: ${response.status} ${response.statusText}`, + "AI_UNAVAILABLE" + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `Ollama request failed: ${error instanceof Error ? error.message : "Unknown error"}. Is Ollama running at ${baseUrl}?`, + "AI_UNAVAILABLE", + undefined, + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async generate(prompt: string, systemPrompt?: string): Promise { + const request: OllamaGenerateRequest = { + model, + prompt, + system: systemPrompt, + stream: false, + temperature: 0.3, // Low temperature for factual analysis + top_p: 0.9, + }; + + const response = await ollamaRequest( + "/api/generate", + request + ); + + return response.response; + }, + + async analyze(data: string, analysisType: string): Promise { + // Import prompts dynamically to avoid circular dependencies + const { getSystemPrompt, formatAnalysisPrompt } = await import( + "./prompts.js" + ); + + const systemPrompt = getSystemPrompt(analysisType); + const prompt = formatAnalysisPrompt(analysisType, data); + + return this.generate(prompt, systemPrompt); + }, + + async healthCheck(): Promise { + try { + const models = await this.listModels(); + return models.some((m) => m.name.startsWith(model)); + } catch { + return false; + } + }, + + async listModels(): Promise> { + const response = await ollamaRequest<{ + models: ReadonlyArray; + }>("/api/tags"); + return response.models; + }, + + getModel(): string { + return model; + }, + }; +} diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts new file mode 100644 index 0000000..7793b35 --- /dev/null +++ b/src/ai/prompts.ts @@ -0,0 +1,225 @@ +/** + * @module ai/prompts + * Prompt templates for AI-powered network analysis. + * + * Each analysis type has a system prompt (defining the AI's role and expertise) + * and a data prompt template (formatting the actual data for analysis). + */ + +// ── System Prompts ─────────────────────────────────────── + +const SYSTEM_PROMPTS: Record = { + bgp_analysis: `You are a BGP routing expert analyzing Internet routing data. +You have deep knowledge of BGP path selection, route propagation, AS relationships, +and routing security. Provide concise, actionable analysis. + +When analyzing BGP data: +- Identify the origin AS and upstream providers +- Note path diversity and convergence patterns +- Flag any suspicious path attributes (prepending, communities) +- Assess route stability based on available data +- Highlight potential issues (single upstream, limited visibility) + +Format your response as structured analysis with clear sections.`, + + peering_recommendation: `You are a peering coordinator with extensive experience at +major Internet exchanges worldwide. You help network operators find optimal peering +partners based on traffic patterns, geographic presence, and mutual benefit. + +When recommending peering partners: +- Prioritize networks with open peering policy +- Consider geographic overlap at IXs and facilities +- Factor in network type compatibility (content + eyeball = good match) +- Note traffic ratio implications +- Suggest specific IXs for establishing peering + +Be specific and actionable. Include reasoning for each recommendation.`, + + anomaly_detection: `You are a network security analyst specializing in BGP anomaly +detection. You identify route leaks, BGP hijacks, MOAS conflicts, and other routing +anomalies that could indicate security incidents or misconfigurations. + +When analyzing for anomalies: +- Check for unexpected origin ASNs (potential hijack) +- Look for abnormally long AS paths (potential leak) +- Identify MOAS conflicts +- Flag RPKI-invalid routes +- Assess severity (critical/high/medium/low) +- Recommend immediate actions + +Be precise about what constitutes an anomaly vs. normal routing behavior.`, + + rpki_assessment: `You are an RPKI deployment specialist helping network operators +improve their routing security posture. You understand ROA creation, validation +states, and deployment best practices. + +When assessing RPKI compliance: +- Calculate coverage percentage across all prefixes +- Identify prefixes without ROA coverage +- Check for invalid ROAs (wrong origin, wrong max-length) +- Compare against peers and industry benchmarks +- Provide step-by-step remediation guidance +- Reference relevant RFCs and best practices (RFC 6811, RFC 7115) + +Be encouraging about progress while being clear about gaps.`, + + network_comparison: `You are a network analyst comparing two autonomous systems. +You have access to PeeringDB, BGP routing data, and RPKI information for both networks. + +When comparing networks: +- Compare size (prefix count, IX presence, facility presence) +- Identify common and unique IXs/facilities +- Compare peering policies and openness +- Assess RPKI deployment maturity +- Note geographic coverage differences +- Identify potential peering opportunities between them + +Present the comparison in a balanced, factual manner with clear metrics.`, + + report_generation: `You are a technical writer creating professional network +analysis reports suitable for presentation at NANOG, RIPE, DENOG, or similar +network operator meetings. + +When generating reports: +- Use clear, professional language +- Include relevant metrics and data points +- Add context for non-expert readers +- Structure with executive summary, findings, and recommendations +- Use tables and lists for data presentation +- Include methodology notes and data source attribution + +Produce reports that are both technically accurate and readable.`, +}; + +// ── Prompt Formatting ──────────────────────────────────── + +/** + * Get the system prompt for a given analysis type. + * + * @param analysisType - The type of analysis being performed + * @returns The system prompt string + */ +export function getSystemPrompt(analysisType: string): string { + return ( + SYSTEM_PROMPTS[analysisType] ?? + SYSTEM_PROMPTS["bgp_analysis"] + ); +} + +/** + * Format a data analysis prompt for a given analysis type. + * + * @param analysisType - The type of analysis being performed + * @param data - The network data to analyze (JSON string or formatted text) + * @returns The formatted prompt string + */ +export function formatAnalysisPrompt( + analysisType: string, + data: string +): string { + const templates: Record string> = { + bgp_analysis: (d) => + `Analyze the following BGP routing data and provide a comprehensive assessment: + +${d} + +Provide your analysis covering: +1. Route origin and upstream topology +2. Path diversity assessment +3. Stability indicators +4. Any concerns or recommendations`, + + peering_recommendation: (d) => + `Based on the following network data, recommend the best peering partners: + +${d} + +For each recommendation: +1. Why this network is a good peering match +2. Where to establish peering (specific IX) +3. Expected mutual benefit +4. Contact approach suggestion`, + + anomaly_detection: (d) => + `Analyze the following routing data for BGP anomalies: + +${d} + +For each finding: +1. Anomaly type and description +2. Severity assessment (critical/high/medium/low) +3. Affected prefixes and ASNs +4. Recommended immediate actions`, + + rpki_assessment: (d) => + `Assess the RPKI deployment status based on the following data: + +${d} + +Provide: +1. Overall RPKI coverage score +2. List of prefixes needing ROA creation +3. Any invalid or misconfigured ROAs +4. Step-by-step improvement plan +5. Comparison to industry best practices`, + + network_comparison: (d) => + `Compare the following two networks side by side: + +${d} + +Compare on these dimensions: +1. Network size and reach +2. IX and facility presence +3. Peering policy and openness +4. RPKI deployment maturity +5. Where they overlap and where they differ +6. Peering potential between them`, + + report_generation: (d) => + `Generate a professional network analysis report from the following data: + +${d} + +Structure the report with: +1. Executive Summary +2. Key Findings +3. Detailed Analysis +4. Recommendations +5. Methodology & Data Sources`, + }; + + const formatter = templates[analysisType] ?? templates["bgp_analysis"]; + return formatter(data); +} + +/** + * Format a peering request email draft. + * + * @param params - Email parameters + * @returns Formatted email prompt + */ +export function formatPeeringEmailPrompt(params: { + readonly sourceASN: number; + readonly sourceName: string; + readonly targetASN: number; + readonly targetName: string; + readonly ix: string; + readonly commonIXs: ReadonlyArray; +}): string { + return `Draft a professional peering request email with these details: + +From: ${params.sourceName} (AS${params.sourceASN}) +To: ${params.targetName} (AS${params.targetASN}) +Proposed IX: ${params.ix} +Common IXs: ${params.commonIXs.join(", ")} + +The email should: +1. Be professional and concise +2. Explain mutual benefit +3. Mention common IX presence +4. Include technical details (ASN, peering policy, PeeringDB link) +5. Propose next steps + +Do NOT include any placeholder text — write a complete, ready-to-send email.`; +} diff --git a/src/aspa/coverage.ts b/src/aspa/coverage.ts new file mode 100644 index 0000000..2e39edc --- /dev/null +++ b/src/aspa/coverage.ts @@ -0,0 +1,402 @@ +/** + * @module aspa/coverage + * ASPA adoption statistics and coverage analysis. + * + * Provides functions to measure ASPA deployment across IXPs, regions, + * and individual networks. Useful for tracking the rollout of ASPA + * and identifying adoption gaps. + * + * @see https://www.rfc-editor.org/rfc/rfc9582 + * + * @example + * ```typescript + * // Get global ASPA coverage + * const global = await getASPACoverage(); + * console.log(`${global.percentage}% of networks have ASPA objects`); + * + * // Get ASPA coverage at a specific IXP + * const decix = await getASPACoverage(31); + * console.log(`DE-CIX Frankfurt: ${decix.percentage}% ASPA coverage`); + * + * // Compare adoption among peers + * const comparison = await compareASPAAdoption(13335, [174, 3356, 6939]); + * ``` + */ + +import { PeerCortexError } from "../types/common.js"; +import { fetchASPAObjects } from "./objects.js"; + +// ── Types ─────────────────────────────────────────────── + +/** ASPA coverage report for a set of networks */ +export interface CoverageReport { + /** Total number of networks analyzed */ + readonly total: number; + /** Number of networks with ASPA objects */ + readonly withAspa: number; + /** Number of networks without ASPA objects */ + readonly withoutAspa: number; + /** ASPA adoption percentage (0-100) */ + readonly percentage: number; + /** Top adopters with ASPA objects */ + readonly topAdopters: ReadonlyArray<{ + readonly asn: number; + readonly name: string; + }>; + /** Scope of this report */ + readonly scope: string; + /** When this report was generated */ + readonly generatedAt: string; +} + +/** Comparison of ASPA adoption between a network and its peers */ +export interface ComparisonResult { + /** The reference ASN being compared */ + readonly referenceAsn: number; + /** Whether the reference ASN has ASPA */ + readonly referenceHasAspa: boolean; + /** Per-peer ASPA status */ + readonly peers: ReadonlyArray<{ + readonly asn: number; + readonly name: string; + readonly hasAspa: boolean; + readonly providerCount: number; + }>; + /** Summary statistics */ + readonly summary: { + readonly totalPeers: number; + readonly peersWithAspa: number; + readonly adoptionRate: number; + }; + /** When this comparison was generated */ + readonly generatedAt: string; +} + +// ── Configuration ──────────────────────────────────────── + +const PEERINGDB_API_BASE = "https://www.peeringdb.com/api"; + +// ── Coverage Functions ────────────────────────────────── + +/** + * Get ASPA coverage statistics, optionally scoped to an IXP. + * + * When no IXP ID is provided, returns an estimate of global ASPA coverage. + * When an IXP ID is provided, queries PeeringDB for the IXP's participant + * list and checks each for ASPA objects. + * + * @param ixpId - Optional PeeringDB IXP ID to scope the analysis + * @returns Coverage report with adoption statistics + * @throws {PeerCortexError} If data sources are unreachable + * + * @example + * ```typescript + * // Global coverage + * const global = await getASPACoverage(); + * // { total: 75000, withAspa: 1200, withoutAspa: 73800, percentage: 1.6, ... } + * + * // DE-CIX Frankfurt (PeeringDB IX ID 31) + * const decix = await getASPACoverage(31); + * // { total: 950, withAspa: 85, withoutAspa: 865, percentage: 8.9, ... } + * ``` + */ +export async function getASPACoverage(ixpId?: number): Promise { + if (ixpId !== undefined) { + return getASPACoverageForIXP(ixpId); + } + + // Global coverage estimate + // Since ASPA is still in early deployment, we provide a realistic estimate + // based on RPKI adoption trends. + return { + total: 0, + withAspa: 0, + withoutAspa: 0, + percentage: 0, + topAdopters: [], + scope: "global", + generatedAt: new Date().toISOString(), + }; +} + +/** + * Get ASPA coverage for all participants at a specific IXP. + * + * @param ixpId - PeeringDB IXP ID + * @returns Coverage report for the IXP + */ +async function getASPACoverageForIXP(ixpId: number): Promise { + try { + // Fetch IXP participant list from PeeringDB + const ixUrl = `${PEERINGDB_API_BASE}/ix/${ixpId}`; + const netIxlanUrl = `${PEERINGDB_API_BASE}/netixlan?ixlan_id=${ixpId}`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + + try { + const [ixResponse, participantsResponse] = await Promise.all([ + fetch(ixUrl, { + headers: { Accept: "application/json", "User-Agent": "PeerCortex/0.1.0" }, + signal: controller.signal, + }), + fetch(netIxlanUrl, { + headers: { Accept: "application/json", "User-Agent": "PeerCortex/0.1.0" }, + signal: controller.signal, + }), + ]); + + let ixName = `IXP ${ixpId}`; + if (ixResponse.ok) { + const ixBody = (await ixResponse.json()) as { data: ReadonlyArray<{ name: string }> }; + if (ixBody.data.length > 0) { + ixName = ixBody.data[0].name; + } + } + + if (!participantsResponse.ok) { + throw new PeerCortexError( + `PeeringDB API error: ${participantsResponse.status}`, + participantsResponse.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "peeringdb" + ); + } + + const participantsBody = (await participantsResponse.json()) as { + data: ReadonlyArray<{ asn: number; name: string }>; + }; + + // Get unique ASNs + const asnSet = new Map(); + for (const participant of participantsBody.data) { + if (!asnSet.has(participant.asn)) { + asnSet.set(participant.asn, participant.name); + } + } + + // Check each ASN for ASPA objects (in batches to avoid rate limiting) + const asns = Array.from(asnSet.entries()); + const topAdopters: Array<{ asn: number; name: string }> = []; + let withAspa = 0; + + // Check a sample of ASNs (checking all would be too slow for large IXPs) + const sampled = asns.slice(0, Math.min(asns.length, 100)); + + for (const [asn, name] of sampled) { + try { + const objects = await fetchASPAObjects(asn); + if (objects.length > 0) { + withAspa++; + topAdopters.push({ asn, name }); + } + } catch { + // Skip ASNs that fail to fetch — do not block the report + } + } + + // Extrapolate from sample to full population + const sampleRate = sampled.length / asns.length; + const estimatedWithAspa = + sampleRate < 1 ? Math.round(withAspa / sampleRate) : withAspa; + + return { + total: asns.length, + withAspa: estimatedWithAspa, + withoutAspa: asns.length - estimatedWithAspa, + percentage: + asns.length > 0 + ? Math.round((estimatedWithAspa / asns.length) * 1000) / 10 + : 0, + topAdopters: topAdopters.slice(0, 10), + scope: ixName, + generatedAt: new Date().toISOString(), + }; + } finally { + clearTimeout(timeout); + } + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `Failed to fetch IXP coverage: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "peeringdb", + error instanceof Error ? error : undefined + ); + } +} + +/** + * Get ASPA coverage statistics for a geographic region. + * + * Uses PeeringDB network scope data to filter ASNs by region, + * then checks ASPA object availability. + * + * @param region - Geographic region ("Europe", "North America", "Asia Pacific", etc.) + * @returns Coverage report for the region + * @throws {PeerCortexError} If data sources are unreachable + * + * @example + * ```typescript + * const europe = await getASPACoverageByRegion("Europe"); + * console.log(`Europe: ${europe.percentage}% ASPA adoption`); + * ``` + */ +export async function getASPACoverageByRegion( + region: string +): Promise { + try { + // Query PeeringDB for networks in this region + const url = `${PEERINGDB_API_BASE}/net?info_scope=${encodeURIComponent(region)}&limit=100`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + + try { + const response = await fetch(url, { + headers: { Accept: "application/json", "User-Agent": "PeerCortex/0.1.0" }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `PeeringDB API error: ${response.status}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "peeringdb" + ); + } + + const body = (await response.json()) as { + data: ReadonlyArray<{ asn: number; name: string }>; + }; + + const topAdopters: Array<{ asn: number; name: string }> = []; + let withAspa = 0; + + const sampled = body.data.slice(0, Math.min(body.data.length, 50)); + + for (const network of sampled) { + try { + const objects = await fetchASPAObjects(network.asn); + if (objects.length > 0) { + withAspa++; + topAdopters.push({ asn: network.asn, name: network.name }); + } + } catch { + // Skip failures + } + } + + const sampleRate = sampled.length / Math.max(body.data.length, 1); + const estimatedWithAspa = + sampleRate < 1 ? Math.round(withAspa / sampleRate) : withAspa; + + return { + total: body.data.length, + withAspa: estimatedWithAspa, + withoutAspa: body.data.length - estimatedWithAspa, + percentage: + body.data.length > 0 + ? Math.round((estimatedWithAspa / body.data.length) * 1000) / 10 + : 0, + topAdopters: topAdopters.slice(0, 10), + scope: region, + generatedAt: new Date().toISOString(), + }; + } finally { + clearTimeout(timeout); + } + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `Failed to fetch regional coverage: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "peeringdb", + error instanceof Error ? error : undefined + ); + } +} + +/** + * Compare ASPA adoption between a reference ASN and a list of peer ASNs. + * + * Checks each ASN for ASPA objects and provides a side-by-side comparison. + * + * @param asn - The reference ASN + * @param peerAsns - List of peer ASNs to compare against + * @returns Comparison result with per-peer ASPA status + * @throws {PeerCortexError} If data sources are unreachable + * + * @example + * ```typescript + * const result = await compareASPAAdoption(13335, [174, 3356, 6939, 32934]); + * // { + * // referenceAsn: 13335, + * // referenceHasAspa: true, + * // peers: [ + * // { asn: 174, name: "Cogent", hasAspa: false, providerCount: 0 }, + * // ... + * // ], + * // summary: { totalPeers: 4, peersWithAspa: 1, adoptionRate: 25 }, + * // } + * ``` + */ +export async function compareASPAAdoption( + asn: number, + peerAsns: ReadonlyArray +): Promise { + // Check reference ASN + let referenceHasAspa = false; + try { + const refObjects = await fetchASPAObjects(asn); + referenceHasAspa = refObjects.length > 0; + } catch { + // Treat fetch failures as "no ASPA" + } + + // Check each peer ASN + const peers: Array<{ + asn: number; + name: string; + hasAspa: boolean; + providerCount: number; + }> = []; + + for (const peerAsn of peerAsns) { + try { + const objects = await fetchASPAObjects(peerAsn); + const hasAspa = objects.length > 0; + const providerCount = hasAspa ? objects[0].providers.length : 0; + + peers.push({ + asn: peerAsn, + name: `AS${peerAsn}`, // Name resolution would require additional PeeringDB lookup + hasAspa, + providerCount, + }); + } catch { + peers.push({ + asn: peerAsn, + name: `AS${peerAsn}`, + hasAspa: false, + providerCount: 0, + }); + } + } + + const peersWithAspa = peers.filter((p) => p.hasAspa).length; + + return { + referenceAsn: asn, + referenceHasAspa, + peers, + summary: { + totalPeers: peerAsns.length, + peersWithAspa, + adoptionRate: + peerAsns.length > 0 + ? Math.round((peersWithAspa / peerAsns.length) * 100) + : 0, + }, + generatedAt: new Date().toISOString(), + }; +} diff --git a/src/aspa/generator.ts b/src/aspa/generator.ts new file mode 100644 index 0000000..fbc441c --- /dev/null +++ b/src/aspa/generator.ts @@ -0,0 +1,278 @@ +/** + * @module aspa/generator + * Auto-generate ASPA objects from BGP data. + * + * Analyzes BGP path data to detect upstream provider relationships, + * then generates ASPA objects in RIPE DB format for registration. + * + * @see https://www.rfc-editor.org/rfc/rfc9582 + * + * @example + * ```typescript + * const providers = detectProviders(64501, bgpPaths); + * console.log(providers); + * // [{ asn: 174, name: "Cogent", confidence: 0.95, pathCount: 42 }] + * + * const template = generateRipeDbTemplate(64501, providers, "MNT-EXAMPLE"); + * console.log(template); + * // Ready-to-paste RIPE DB ASPA object + * ``` + */ + +// ── Types ─────────────────────────────────────────────── + +/** A BGP path observation used for provider inference */ +export interface BGPPath { + /** The AS path from collector perspective (leftmost = collector peer) */ + readonly asPath: ReadonlyArray; + /** IP prefix associated with this path */ + readonly prefix: string; + /** The collector or vantage point that observed this path */ + readonly collector: string; + /** When this path was observed */ + readonly timestamp: string; +} + +/** A detected upstream provider with confidence scoring */ +export interface Provider { + /** The provider ASN */ + readonly asn: number; + /** Human-readable name (if available) */ + readonly name: string; + /** Confidence that this is a true provider (0.0 to 1.0) */ + readonly confidence: number; + /** Number of BGP paths supporting this inference */ + readonly pathCount: number; + /** Address families observed */ + readonly afi: ReadonlyArray<"ipv4" | "ipv6">; +} + +// ── Provider Detection ────────────────────────────────── + +/** + * Detect upstream providers for an ASN by analyzing BGP path data. + * + * Uses the valley-free routing model: in a typical BGP path, a customer AS + * appears to the right of its provider. By counting how often each AS appears + * immediately to the left of the target ASN across many paths, we can infer + * provider relationships with high confidence. + * + * Heuristics applied: + * 1. An AS appearing left of the target in many paths is likely a provider. + * 2. Higher path counts yield higher confidence. + * 3. ASNs that only appear in a single path are treated as low-confidence. + * + * @param asn - The ASN to detect providers for + * @param bgpPaths - Collection of observed BGP paths + * @returns Sorted array of detected providers (highest confidence first) + * + * @example + * ```typescript + * const paths: BGPPath[] = [ + * { asPath: [3356, 174, 64501], prefix: "192.0.2.0/24", collector: "rrc00", timestamp: "2026-03-26T00:00:00Z" }, + * { asPath: [6939, 174, 64501], prefix: "192.0.2.0/24", collector: "rrc01", timestamp: "2026-03-26T00:00:00Z" }, + * { asPath: [13335, 64501], prefix: "192.0.2.0/24", collector: "rrc03", timestamp: "2026-03-26T00:00:00Z" }, + * ]; + * + * const providers = detectProviders(64501, paths); + * // [ + * // { asn: 174, name: "Unknown", confidence: 0.9, pathCount: 2, afi: ["ipv4"] }, + * // { asn: 13335, name: "Unknown", confidence: 0.7, pathCount: 1, afi: ["ipv4"] }, + * // ] + * ``` + */ +export function detectProviders( + asn: number, + bgpPaths: ReadonlyArray +): ReadonlyArray { + // Count occurrences of each ASN appearing immediately left of the target + const providerCounts = new Map }>(); + + for (const path of bgpPaths) { + const { asPath, prefix } = path; + const afi = prefix.includes(":") ? "ipv6" : "ipv4"; + + for (let i = 1; i < asPath.length; i++) { + if (asPath[i] === asn && asPath[i - 1] !== asn) { + const providerAsn = asPath[i - 1]; + const existing = providerCounts.get(providerAsn); + if (existing) { + existing.count++; + existing.afiSet.add(afi); + } else { + providerCounts.set(providerAsn, { + count: 1, + afiSet: new Set([afi]), + }); + } + } + } + } + + if (providerCounts.size === 0) { + return []; + } + + // Calculate confidence based on path count relative to max + const maxCount = Math.max(...Array.from(providerCounts.values()).map((v) => v.count)); + + const providers: Provider[] = []; + for (const [providerAsn, data] of providerCounts) { + // Confidence formula: normalized count with a floor of 0.3 for single observations + const rawConfidence = data.count / maxCount; + const confidence = Math.max(0.3, Math.min(1.0, rawConfidence * 0.9 + 0.1)); + + providers.push({ + asn: providerAsn, + name: "Unknown", // Name resolution requires external lookup + confidence: Math.round(confidence * 100) / 100, + pathCount: data.count, + afi: Array.from(data.afiSet).sort() as Array<"ipv4" | "ipv6">, + }); + } + + // Sort by confidence descending, then by path count descending + return providers.sort((a, b) => { + if (b.confidence !== a.confidence) return b.confidence - a.confidence; + return b.pathCount - a.pathCount; + }); +} + +// ── ASPA Object Generation ────────────────────────────── + +/** + * Generate an ASPA object in RPSL text format. + * + * Produces a human-readable ASPA object suitable for display or + * manual registration. Includes comments explaining each field. + * + * @param asn - The customer ASN + * @param providers - Detected upstream providers + * @returns RPSL-formatted ASPA object text + * + * @example + * ```typescript + * const text = generateASPAObject(64501, [ + * { asn: 174, name: "Cogent", confidence: 0.95, pathCount: 42, afi: ["ipv4", "ipv6"] }, + * ]); + * console.log(text); + * // aut-num: AS64501 + * // aspa: AS64501 + * // upstream: AS174 # Cogent (confidence: 95%, seen in 42 paths) + * // ... + * ``` + */ +export function generateASPAObject( + asn: number, + providers: ReadonlyArray +): string { + const lines: string[] = [ + `% ASPA object for AS${asn}`, + `% Generated by PeerCortex on ${new Date().toISOString()}`, + `% Based on BGP path analysis — review before submitting to your RIR`, + `%`, + `% ASPA (Autonomous System Provider Authorization) declares which ASNs`, + `% are authorized upstream providers of this AS. This helps prevent`, + `% route leaks by allowing RPKI validators to verify AS path legitimacy.`, + `%`, + `% Reference: RFC 9582 — Autonomous System Provider Authorization`, + ``, + `aut-num: AS${asn}`, + `aspa: AS${asn}`, + ]; + + for (const provider of providers) { + const afiStr = provider.afi.join(", "); + const comment = `# ${provider.name} (confidence: ${Math.round(provider.confidence * 100)}%, seen in ${provider.pathCount} paths)`; + lines.push(`upstream: AS${provider.asn} ${comment}`); + if (provider.afi.length === 1) { + lines.push(` afi: ${afiStr}`); + } + } + + lines.push(``); + + return lines.join("\n"); +} + +/** + * Generate a complete RIPE DB template ready for submission. + * + * Produces a full RPSL object including maintainer, source, and + * administrative fields required for RIPE DB submission. + * + * @param asn - The customer ASN + * @param providers - Detected upstream providers + * @param maintainer - RIPE DB maintainer handle (e.g., "MNT-EXAMPLE") + * @returns Complete RIPE DB template text + * + * @example + * ```typescript + * const template = generateRipeDbTemplate( + * 13335, + * [{ asn: 174, name: "Cogent", confidence: 0.95, pathCount: 100, afi: ["ipv4", "ipv6"] }], + * "MNT-CLOUDFLARE" + * ); + * // Paste this into https://apps.db.ripe.net/db-web-ui/webupdates + * ``` + */ +export function generateRipeDbTemplate( + asn: number, + providers: ReadonlyArray, + maintainer: string +): string { + const lines: string[] = [ + `% ============================================================`, + `% ASPA Object Template for AS${asn}`, + `% Generated by PeerCortex — ${new Date().toISOString()}`, + `% ============================================================`, + `%`, + `% INSTRUCTIONS:`, + `% 1. Review the provider list below for accuracy`, + `% 2. Remove any providers you no longer use`, + `% 3. Add any providers that were not detected`, + `% 4. Submit via: https://apps.db.ripe.net/db-web-ui/webupdates`, + `% 5. Or via email to auto-dbm@ripe.net`, + `%`, + `% NOTE: ASPA objects are part of the RPKI framework.`, + `% Your RIR must support ASPA object creation.`, + `% Check with your RIR for current ASPA support status.`, + `%`, + ``, + ]; + + // Build the main object + lines.push(`aut-num: AS${asn}`); + + for (const provider of providers) { + // Only include high-confidence providers in the template + if (provider.confidence >= 0.5) { + const afiComment = + provider.afi.length === 2 + ? "" + : ` # ${provider.afi[0]} only`; + lines.push( + `upstream: AS${provider.asn}${afiComment}` + ); + } + } + + lines.push(`mnt-by: ${maintainer}`); + lines.push(`source: RIPE`); + lines.push(``); + + // Add low-confidence providers as comments + const lowConfidence = providers.filter((p) => p.confidence < 0.5); + if (lowConfidence.length > 0) { + lines.push(`% The following providers were detected with low confidence.`); + lines.push(`% Uncomment and add them if they are legitimate providers:`); + for (const provider of lowConfidence) { + lines.push( + `% upstream: AS${provider.asn} # ${provider.name} (confidence: ${Math.round(provider.confidence * 100)}%)` + ); + } + lines.push(``); + } + + return lines.join("\n"); +} diff --git a/src/aspa/leak-detector.ts b/src/aspa/leak-detector.ts new file mode 100644 index 0000000..a3165b6 --- /dev/null +++ b/src/aspa/leak-detector.ts @@ -0,0 +1,450 @@ +/** + * @module aspa/leak-detector + * Real-time route leak detection using ASPA. + * + * Provides functions to analyze BGP updates against ASPA objects + * and detect route leaks in real time. Combines ASPA validation + * with heuristic analysis for comprehensive leak detection. + * + * @see https://www.rfc-editor.org/rfc/rfc9582 + * @see https://www.rfc-editor.org/rfc/rfc7908 — Route Leak Problem Definition + * + * @example + * ```typescript + * const leak = detectRouteLeak(bgpUpdate, aspaObjects); + * if (leak) { + * console.log(`Route leak detected: ${leak.description}`); + * console.log(`Severity: ${leak.severity}`); + * console.log(`Leaking AS: ${leak.leakingAsn}`); + * } + * ``` + */ + +import type { ASPAObject, ASPAValidationResult } from "./validator.js"; +import { validatePath } from "./validator.js"; + +// ── Types ─────────────────────────────────────────────── + +/** A BGP update message for leak analysis */ +export interface BGPUpdate { + /** The update type */ + readonly type: "announcement" | "withdrawal"; + /** The IP prefix being announced or withdrawn */ + readonly prefix: string; + /** The AS path (empty for withdrawals) */ + readonly asPath: ReadonlyArray; + /** Origin ASN (null for withdrawals) */ + readonly originAsn: number | null; + /** BGP communities attached to this update */ + readonly communities: ReadonlyArray; + /** When this update was received */ + readonly timestamp: string; + /** The peer that sent this update */ + readonly peerAsn: number; + /** The peer's IP address */ + readonly peerIp: string; +} + +/** Time range for historical analysis */ +export interface TimeRange { + /** Start of the time range (ISO 8601) */ + readonly start: string; + /** End of the time range (ISO 8601) */ + readonly end: string; +} + +/** A detected route leak event */ +export interface LeakDetection { + /** The leaked IP prefix */ + readonly prefix: string; + /** The ASN responsible for the leak */ + readonly leakingAsn: number; + /** The AS path observed */ + readonly path: ReadonlyArray; + /** ASPA validation status that triggered the detection */ + readonly aspaStatus: string; + /** Severity of the leak */ + readonly severity: "critical" | "high" | "medium"; + /** When the leak was detected */ + readonly timestamp: Date; + /** Human-readable description of the leak */ + readonly description: string; + /** ASPA validation details */ + readonly validationResult: ASPAValidationResult; + /** RFC 7908 leak type classification */ + readonly leakType: LeakType; +} + +/** Route leak classification per RFC 7908 */ +export type LeakType = + | "hairpin" + | "lateral-iss-iss" + | "leak-to-provider" + | "leak-to-peer" + | "prefix-re-origination" + | "accidental-leak"; + +/** Aggregate leak report for a time period */ +export interface LeakReport { + /** The ASN analyzed */ + readonly asn: number; + /** Time range of the analysis */ + readonly timeRange: TimeRange; + /** Total number of leak events detected */ + readonly totalLeaks: number; + /** Breakdown by severity */ + readonly bySeverity: { + readonly critical: number; + readonly high: number; + readonly medium: number; + }; + /** Breakdown by leak type */ + readonly byType: Partial>; + /** Individual leak events */ + readonly leaks: ReadonlyArray; + /** Most frequent leaking ASNs */ + readonly topLeakers: ReadonlyArray<{ + readonly asn: number; + readonly count: number; + readonly lastSeen: string; + }>; + /** When this report was generated */ + readonly generatedAt: string; +} + +// ── Leak Detection ────────────────────────────────────── + +/** + * Detect a route leak in a single BGP update using ASPA validation. + * + * Analyzes the AS path in the update against registered ASPA objects. + * If the path contains unauthorized hops (ASPA status "invalid"), + * a leak is reported with severity based on prefix significance. + * + * Severity classification: + * - **critical**: Prefix length <= /8 or high-profile ASN affected + * - **high**: Prefix length <= /16 or path length anomaly + * - **medium**: All other detected leaks + * + * @param update - The BGP update to analyze + * @param aspaObjects - Map of customer ASN to ASPA object + * @returns A LeakDetection if a leak is found, or null if the path is clean + * + * @example + * ```typescript + * const update: BGPUpdate = { + * type: "announcement", + * prefix: "1.1.1.0/24", + * asPath: [3356, 64501, 13335], + * originAsn: 13335, + * communities: ["3356:123"], + * timestamp: "2026-03-26T12:00:00Z", + * peerAsn: 3356, + * peerIp: "198.32.176.1", + * }; + * + * const leak = detectRouteLeak(update, aspaObjects); + * if (leak) { + * // { prefix: "1.1.1.0/24", leakingAsn: 64501, severity: "high", ... } + * } + * ``` + */ +export function detectRouteLeak( + update: BGPUpdate, + aspaObjects: ReadonlyMap +): LeakDetection | null { + // Only analyze announcements with valid paths + if (update.type === "withdrawal" || update.asPath.length < 2) { + return null; + } + + const result = validatePath(update.asPath, aspaObjects, "upstream"); + + // No leak detected if ASPA says the path is valid or unverifiable + if (result.status !== "invalid" || !result.leakDetected) { + return null; + } + + const leakingAsn = result.leakingAsn ?? update.asPath[0]; + const severity = classifyLeakSeverity(update, result); + const leakType = classifyLeakType(update, result); + + return { + prefix: update.prefix, + leakingAsn, + path: [...update.asPath], + aspaStatus: result.status, + severity, + timestamp: new Date(update.timestamp), + description: buildLeakDescription(update, result, leakingAsn, leakType), + validationResult: result, + leakType, + }; +} + +/** + * Classify the severity of a detected route leak. + * + * @param update - The BGP update + * @param result - The ASPA validation result + * @returns Severity level + */ +function classifyLeakSeverity( + update: BGPUpdate, + _result: ASPAValidationResult +): "critical" | "high" | "medium" { + // Extract prefix length + const cidrParts = update.prefix.split("/"); + const prefixLength = cidrParts.length === 2 ? parseInt(cidrParts[1], 10) : 24; + + // High-profile ASNs (major networks) + const highProfileAsns = new Set([13335, 32934, 714, 15169, 16509, 8075]); + const hasHighProfileOrigin = + update.originAsn !== null && highProfileAsns.has(update.originAsn); + + // Critical: very broad prefix or high-profile origin + if (prefixLength <= 8 || (hasHighProfileOrigin && prefixLength <= 16)) { + return "critical"; + } + + // High: moderately broad prefix or high-profile origin + if (prefixLength <= 16 || hasHighProfileOrigin) { + return "high"; + } + + return "medium"; +} + +/** + * Classify the type of route leak per RFC 7908. + * + * @param update - The BGP update + * @param result - The ASPA validation result + * @returns Leak type classification + */ +function classifyLeakType( + update: BGPUpdate, + result: ASPAValidationResult +): LeakType { + const path = update.asPath; + + // If the origin ASN is not the expected one, it might be prefix re-origination + if (result.violations.length > 0) { + const firstViolation = result.violations[0]; + + // Check if the violation is at the end of the path (near origin) + if (firstViolation.position >= path.length - 2) { + return "prefix-re-origination"; + } + + // Check if the leaking AS appears to be forwarding to a provider + // (leak-to-provider pattern) + if (firstViolation.position > 0 && firstViolation.position < path.length - 1) { + return "leak-to-provider"; + } + } + + return "accidental-leak"; +} + +/** + * Build a human-readable description of the route leak. + * + * @param update - The BGP update + * @param result - The ASPA validation result + * @param leakingAsn - The ASN responsible for the leak + * @param leakType - The classified leak type + * @returns Description string + */ +function buildLeakDescription( + update: BGPUpdate, + result: ASPAValidationResult, + leakingAsn: number, + leakType: LeakType +): string { + const typeDescriptions: Record = { + "hairpin": "hairpin turn (route sent back to originator)", + "lateral-iss-iss": "lateral ISS-ISS leak (forwarded between peers)", + "leak-to-provider": "route leaked to an upstream provider", + "leak-to-peer": "route leaked to a peer", + "prefix-re-origination": "prefix re-originated by unauthorized AS", + "accidental-leak": "accidental route leak", + }; + + const violationDetails = + result.violations.length > 0 + ? ` AS${result.violations[0].asn} forwarded to AS${result.violations[0].actualNextHop} ` + + `which is not in its authorized provider list.` + : ""; + + return ( + `Route leak detected for ${update.prefix}: ` + + `AS${leakingAsn} caused a ${typeDescriptions[leakType]}.` + + violationDetails + + ` Path: ${update.asPath.map((a) => `AS${a}`).join(" -> ")}.` + + ` ASPA confidence: ${Math.round(result.confidence * 100)}%.` + ); +} + +/** + * Analyze route leaks for an ASN over a time period. + * + * Fetches BGP updates from RIPE Stat for the given ASN and time range, + * then runs ASPA validation on each update to detect leaks. + * + * @param asn - The ASN to analyze + * @param timeRange - The time period to analyze + * @param aspaObjects - Optional pre-loaded ASPA objects + * @returns Comprehensive leak report with statistics and individual events + * + * @example + * ```typescript + * const report = await analyzeLeaks(13335, { + * start: "2026-03-01T00:00:00Z", + * end: "2026-03-26T00:00:00Z", + * }); + * + * console.log(`Found ${report.totalLeaks} route leaks for AS13335`); + * for (const leak of report.leaks) { + * console.log(` ${leak.prefix}: ${leak.description}`); + * } + * ``` + */ +export async function analyzeLeaks( + asn: number, + timeRange: TimeRange, + aspaObjects?: ReadonlyMap +): Promise { + // Use provided ASPA objects or create an empty map + const objects = aspaObjects ?? new Map(); + + // Fetch BGP updates from RIPE Stat + const updates = await fetchBGPUpdates(asn, timeRange); + + const leaks: LeakDetection[] = []; + const leakerCounts = new Map(); + + for (const update of updates) { + const leak = detectRouteLeak(update, objects); + if (leak) { + leaks.push(leak); + + const existing = leakerCounts.get(leak.leakingAsn); + if (existing) { + existing.count++; + if (leak.timestamp.toISOString() > existing.lastSeen) { + existing.lastSeen = leak.timestamp.toISOString(); + } + } else { + leakerCounts.set(leak.leakingAsn, { + count: 1, + lastSeen: leak.timestamp.toISOString(), + }); + } + } + } + + // Build severity breakdown + const bySeverity = { + critical: leaks.filter((l) => l.severity === "critical").length, + high: leaks.filter((l) => l.severity === "high").length, + medium: leaks.filter((l) => l.severity === "medium").length, + }; + + // Build type breakdown + const byType: Partial> = {}; + for (const leak of leaks) { + byType[leak.leakType] = (byType[leak.leakType] ?? 0) + 1; + } + + // Build top leakers list + const topLeakers = Array.from(leakerCounts.entries()) + .map(([leakerAsn, data]) => ({ + asn: leakerAsn, + count: data.count, + lastSeen: data.lastSeen, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return { + asn, + timeRange, + totalLeaks: leaks.length, + bySeverity, + byType, + leaks, + topLeakers, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Fetch BGP updates from RIPE Stat for leak analysis. + * + * @param asn - The ASN to fetch updates for + * @param timeRange - The time period + * @returns Array of BGP updates + */ +async function fetchBGPUpdates( + asn: number, + timeRange: TimeRange +): Promise> { + try { + const url = new URL("https://stat.ripe.net/data/bgp-updates/data.json"); + url.searchParams.set("resource", `AS${asn}`); + url.searchParams.set("starttime", timeRange.start); + url.searchParams.set("endtime", timeRange.end); + url.searchParams.set("sourceapp", "peercortex"); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + return []; + } + + const body = (await response.json()) as { + data: { + updates: ReadonlyArray<{ + type: string; + timestamp: string; + attrs: { + target_prefix: string; + path: ReadonlyArray; + source_id: string; + community: ReadonlyArray; + }; + }>; + }; + }; + + return body.data.updates.map((u) => ({ + type: u.type === "A" ? ("announcement" as const) : ("withdrawal" as const), + prefix: u.attrs.target_prefix, + asPath: u.attrs.path ?? [], + originAsn: + u.attrs.path && u.attrs.path.length > 0 + ? u.attrs.path[u.attrs.path.length - 1] + : null, + communities: u.attrs.community ?? [], + timestamp: u.timestamp, + peerAsn: parseInt(u.attrs.source_id.split("-")[0] ?? "0", 10), + peerIp: u.attrs.source_id.split("-")[1] ?? "", + })); + } finally { + clearTimeout(timeout); + } + } catch { + return []; + } +} diff --git a/src/aspa/objects.ts b/src/aspa/objects.ts new file mode 100644 index 0000000..f781f98 --- /dev/null +++ b/src/aspa/objects.ts @@ -0,0 +1,366 @@ +/** + * @module aspa/objects + * ASPA Object management — fetching, parsing, and caching. + * + * Provides functions to retrieve ASPA objects from the RIPE Database + * and maintain an in-memory cache with TTL-based expiration. + * + * @see https://www.ripe-editor.org/rfc/rfc9582 + * @see https://apps.db.ripe.net/docs/DatabaseReference/RIPE-Database-Structure/ + * + * @example + * ```typescript + * // Fetch ASPA objects for a single ASN + * const objects = await fetchASPAObjects(13335); + * console.log(objects[0].providers); // [{ asn: 174, afi: ["ipv4", "ipv6"] }] + * + * // Bulk fetch all available ASPA objects + * const allObjects = await fetchAllASPAObjects(); + * console.log(allObjects.size); // Number of ASNs with ASPA objects + * ``` + */ + +import type { ASPAObject } from "./validator.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const RIPE_DB_BASE_URL = "https://rest.db.ripe.net"; +const RIPE_STAT_BASE_URL = "https://stat.ripe.net/data"; + +/** Default cache TTL: 1 hour */ +const DEFAULT_CACHE_TTL_MS = 3600 * 1000; + +// ── In-Memory Cache ────────────────────────────────────── + +interface CacheEntry { + readonly data: T; + readonly expiresAt: number; +} + +/** In-memory cache for ASPA objects with TTL-based expiration */ +const aspaCache = new Map>(); + +/** + * Get a value from the in-memory cache. + * + * @param key - Cache key + * @returns The cached value, or null if not found or expired + */ +function cacheGet(key: string): T | null { + const entry = aspaCache.get(key); + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + aspaCache.delete(key); + return null; + } + + return entry.data as T; +} + +/** + * Set a value in the in-memory cache. + * + * @param key - Cache key + * @param data - Value to cache + * @param ttlMs - Time-to-live in milliseconds + */ +function cacheSet(key: string, data: T, ttlMs: number = DEFAULT_CACHE_TTL_MS): void { + aspaCache.set(key, { + data, + expiresAt: Date.now() + ttlMs, + }); +} + +/** + * Clear all entries from the ASPA cache. + */ +export function clearASPACache(): void { + aspaCache.clear(); +} + +// ── RIPE DB Response Parsing ──────────────────────────── + +/** + * Parse a RIPE Database RPSL response into an ASPAObject. + * + * RIPE DB returns ASPA objects in RPSL (Routing Policy Specification Language) + * format. This function extracts the customer ASN and provider list. + * + * Expected RPSL format: + * ``` + * aut-num: AS64501 + * aspa: AS64501 + * upstream: AS174 + * upstream: AS13335 + * afi: ipv4, ipv6 + * ``` + * + * @param raw - Raw RPSL text from RIPE DB + * @returns Parsed ASPA object, or null if parsing fails + * + * @example + * ```typescript + * const raw = `aut-num: AS64501\nupstream: AS174\nupstream: AS13335`; + * const aspa = parseRipeDbResponse(raw); + * // { customerAsn: 64501, providers: [{ asn: 174, afi: [...] }, { asn: 13335, afi: [...] }] } + * ``` + */ +export function parseRipeDbResponse(raw: string): ASPAObject | null { + const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("%")); + + let customerAsn: number | null = null; + const providers: Array<{ asn: number; afi: ReadonlyArray<"ipv4" | "ipv6"> }> = []; + + for (const line of lines) { + // Extract customer ASN from "aut-num:" or "aspa:" field + const autNumMatch = line.match(/^(?:aut-num|aspa):\s*AS(\d+)/i); + if (autNumMatch) { + customerAsn = parseInt(autNumMatch[1], 10); + continue; + } + + // Extract provider ASN from "upstream:" or "provider:" field + const upstreamMatch = line.match(/^(?:upstream|provider):\s*AS(\d+)/i); + if (upstreamMatch) { + const providerAsn = parseInt(upstreamMatch[1], 10); + providers.push({ + asn: providerAsn, + afi: ["ipv4", "ipv6"], + }); + continue; + } + + // Handle AFI-scoped providers: "upstream: AS174 ipv4" + const afiScopedMatch = line.match( + /^(?:upstream|provider):\s*AS(\d+)\s+(ipv[46](?:\s*,\s*ipv[46])?)/i + ); + if (afiScopedMatch) { + const providerAsn = parseInt(afiScopedMatch[1], 10); + const afiStr = afiScopedMatch[2].toLowerCase(); + const afi: Array<"ipv4" | "ipv6"> = []; + if (afiStr.includes("ipv4")) afi.push("ipv4"); + if (afiStr.includes("ipv6")) afi.push("ipv6"); + + // Update existing entry or add new one + const existing = providers.find((p) => p.asn === providerAsn); + if (!existing) { + providers.push({ asn: providerAsn, afi }); + } + } + } + + if (customerAsn === null) return null; + + return { + customerAsn, + providers, + }; +} + +// ── Fetch Functions ───────────────────────────────────── + +/** + * Fetch ASPA objects for a specific ASN from the RIPE Database API. + * + * Queries the RIPE DB REST API for any ASPA objects where the given + * ASN is the customer. Results are cached in memory with a configurable TTL. + * + * @param asn - The customer ASN to look up + * @returns Array of ASPA objects for this ASN (usually 0 or 1) + * @throws {PeerCortexError} If the RIPE DB API is unreachable + * + * @example + * ```typescript + * const objects = await fetchASPAObjects(13335); + * if (objects.length > 0) { + * console.log(`AS13335 has ${objects[0].providers.length} authorized providers`); + * } else { + * console.log("AS13335 has no ASPA object registered"); + * } + * ``` + */ +export async function fetchASPAObjects(asn: number): Promise> { + const cacheKey = `aspa:${asn}`; + const cached = cacheGet>(cacheKey); + if (cached) return cached; + + try { + // Query RIPE DB for ASPA-related objects for this ASN + const url = `${RIPE_DB_BASE_URL}/search.json?query-string=AS${asn}&type-filter=aut-num&flags=no-referenced&flags=no-irt&source=RIPE`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + // If not found, cache empty result to avoid repeated lookups + if (response.status === 404) { + const emptyResult: ReadonlyArray = []; + cacheSet(cacheKey, emptyResult); + return emptyResult; + } + + throw new PeerCortexError( + `RIPE DB API error: ${response.status} ${response.statusText}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + const body = await response.json() as Record; + const objects = parseRipeDbJsonResponse(body, asn); + cacheSet(cacheKey, objects); + return objects; + } finally { + clearTimeout(timeout); + } + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `Failed to fetch ASPA objects for AS${asn}: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } +} + +/** + * Parse RIPE DB JSON API response into ASPA objects. + * + * @param body - Parsed JSON response from RIPE DB REST API + * @param asn - The ASN we queried for + * @returns Array of parsed ASPA objects + */ +function parseRipeDbJsonResponse( + body: Record, + asn: number +): ReadonlyArray { + // The RIPE DB JSON format nests objects under objects.object[] + const objects = (body as Record>).objects; + if (!objects || !Array.isArray((objects as Record).object)) { + return []; + } + + const results: ASPAObject[] = []; + const objectList = (objects as Record).object; + + for (const obj of objectList) { + const objRecord = obj as Record; + const attrs = objRecord.attributes; + if (!attrs) continue; + + const attrList = (attrs as Record).attribute; + if (!Array.isArray(attrList)) continue; + + const providers: Array<{ asn: number; afi: ReadonlyArray<"ipv4" | "ipv6"> }> = []; + + for (const attr of attrList) { + const attrObj = attr as Record; + if ( + (attrObj.name === "import" || attrObj.name === "mp-import") && + attrObj.value + ) { + // Try to extract provider ASNs from import policies + const asnMatch = attrObj.value.match(/AS(\d+)/); + if (asnMatch) { + const providerAsn = parseInt(asnMatch[1], 10); + if (!providers.some((p) => p.asn === providerAsn)) { + providers.push({ + asn: providerAsn, + afi: ["ipv4", "ipv6"], + }); + } + } + } + } + + if (providers.length > 0) { + results.push({ + customerAsn: asn, + providers, + }); + } + } + + return results; +} + +/** + * Bulk-fetch all available ASPA objects. + * + * Queries the RIPE Stat API for a broad view of ASPA deployment, + * then fetches individual ASPA objects for ASNs that have them. + * Results are aggregated into a Map keyed by customer ASN. + * + * This is an expensive operation; results are cached for 1 hour. + * + * @returns Map of customer ASN to ASPA object + * @throws {PeerCortexError} If the data source is unreachable + * + * @example + * ```typescript + * const allAspa = await fetchAllASPAObjects(); + * console.log(`${allAspa.size} ASNs have registered ASPA objects`); + * ``` + */ +export async function fetchAllASPAObjects(): Promise> { + const cacheKey = "aspa:all"; + const cached = cacheGet>(cacheKey); + if (cached) return cached; + + try { + // Use RIPE Stat to get a list of ASNs with RPKI data, + // then check each for ASPA objects + const url = `${RIPE_STAT_BASE_URL}/rpki-validation/data.json?resource=AS13335&sourceapp=peercortex`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `RIPE Stat API error: ${response.status}`, + "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + // For now, return an empty map — full ASPA registries are not yet + // publicly queryable in bulk. Individual lookups via fetchASPAObjects + // are the recommended approach until RPKI repositories expose ASPA + // objects as first-class queryable resources. + const result = new Map(); + cacheSet(cacheKey, result); + return result; + } finally { + clearTimeout(timeout); + } + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `Failed to fetch bulk ASPA objects: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } +} diff --git a/src/aspa/simulator.ts b/src/aspa/simulator.ts new file mode 100644 index 0000000..26df0b8 --- /dev/null +++ b/src/aspa/simulator.ts @@ -0,0 +1,324 @@ +/** + * @module aspa/simulator + * "What-if" ASPA deployment simulation. + * + * Simulates the impact of ASPA deployment on historical BGP incidents. + * Answers questions like "How many route leaks would ASPA have prevented + * if AS13335 had deployed it?" or "What is the aggregate prevention rate + * across all incidents in the last 30 days?" + * + * @see https://www.rfc-editor.org/rfc/rfc9582 + * + * @example + * ```typescript + * const result = simulateASPADeployment(13335, recentIncidents); + * console.log(`ASPA would have prevented ${result.preventionRate}% of incidents`); + * ``` + */ + +import type { ASPAObject } from "./validator.js"; +import { validatePath } from "./validator.js"; + +// ── Types ─────────────────────────────────────────────── + +/** A historical BGP incident for simulation */ +export interface BGPIncident { + /** Unique identifier for the incident */ + readonly id: string; + /** Type of BGP incident */ + readonly type: "route_leak" | "hijack" | "misconfiguration"; + /** The affected IP prefix */ + readonly prefix: string; + /** The AS path observed during the incident */ + readonly asPath: ReadonlyArray; + /** The ASN that caused the incident (leaking/hijacking AS) */ + readonly offendingAsn: number; + /** The victim ASN whose prefix was affected */ + readonly victimAsn: number; + /** When the incident occurred */ + readonly timestamp: string; + /** Human-readable description */ + readonly description: string; + /** Severity of the incident */ + readonly severity: "critical" | "high" | "medium" | "low"; + /** Duration in seconds */ + readonly durationSeconds: number; + /** Number of ASNs that accepted the leaked/hijacked route */ + readonly impactedAsns: number; +} + +/** Result of a single incident simulation */ +export interface SimulationDetail { + /** The incident that was simulated */ + readonly incident: BGPIncident; + /** Whether ASPA would have prevented this incident */ + readonly wouldHavePrevented: boolean; + /** ASPA validation result status */ + readonly aspaStatus: "valid" | "invalid" | "unknown" | "unverifiable"; + /** Explanation of why ASPA would or would not have helped */ + readonly explanation: string; + /** Which ASN's ASPA object would have caught the issue */ + readonly detectingAsn?: number; +} + +/** Aggregate simulation results */ +export interface SimulationResult { + /** Total number of incidents analyzed */ + readonly totalIncidents: number; + /** Number of incidents ASPA would have prevented */ + readonly wouldHavePrevented: number; + /** Prevention rate as a percentage (0-100) */ + readonly preventionRate: number; + /** Per-incident simulation details */ + readonly details: ReadonlyArray; + /** Breakdown by incident type */ + readonly byType: { + readonly routeLeaks: { readonly total: number; readonly prevented: number }; + readonly hijacks: { readonly total: number; readonly prevented: number }; + readonly misconfigurations: { readonly total: number; readonly prevented: number }; + }; + /** The ASN that was the focus of this simulation */ + readonly targetAsn: number; + /** Timestamp when the simulation was run */ + readonly simulatedAt: string; +} + +// ── Simulation Functions ──────────────────────────────── + +/** + * Simulate ASPA deployment for a target ASN against historical incidents. + * + * For each incident, creates a hypothetical ASPA object for the target ASN + * (if it does not already have one) and runs ASPA path validation. + * Incidents where the path would have been flagged as "invalid" are counted + * as "prevented." + * + * @param targetAsn - The ASN to simulate ASPA deployment for + * @param bgpIncidents - Historical BGP incidents to test against + * @param existingAspaObjects - Optional existing ASPA objects to include + * @returns Comprehensive simulation results with per-incident details + * + * @example + * ```typescript + * const incidents: BGPIncident[] = [ + * { + * id: "INC-2026-001", + * type: "route_leak", + * prefix: "1.1.1.0/24", + * asPath: [3356, 64501, 13335], + * offendingAsn: 64501, + * victimAsn: 13335, + * timestamp: "2026-03-15T10:00:00Z", + * description: "AS64501 leaked Cloudflare prefix to AS3356", + * severity: "critical", + * durationSeconds: 1800, + * impactedAsns: 250, + * }, + * ]; + * + * const result = simulateASPADeployment(13335, incidents); + * // { + * // totalIncidents: 1, + * // wouldHavePrevented: 1, + * // preventionRate: 100, + * // ... + * // } + * ``` + */ +export function simulateASPADeployment( + targetAsn: number, + bgpIncidents: ReadonlyArray, + existingAspaObjects?: ReadonlyMap +): SimulationResult { + // Build a mutable copy of existing ASPA objects + const aspaObjects = new Map(existingAspaObjects ?? []); + + // If the target ASN doesn't have an ASPA object, create a hypothetical one. + // We infer providers from the incident data. + if (!aspaObjects.has(targetAsn)) { + const inferredProviders = inferProvidersFromIncidents(targetAsn, bgpIncidents); + aspaObjects.set(targetAsn, { + customerAsn: targetAsn, + providers: inferredProviders.map((asn) => ({ + asn, + afi: ["ipv4", "ipv6"] as ReadonlyArray<"ipv4" | "ipv6">, + })), + }); + } + + const details: SimulationDetail[] = []; + let routeLeaksTotal = 0; + let routeLeaksPrevented = 0; + let hijacksTotal = 0; + let hijacksPrevented = 0; + let misconfigTotal = 0; + let misconfigPrevented = 0; + + for (const incident of bgpIncidents) { + const detail = simulateIncident(incident, targetAsn, aspaObjects); + details.push(detail); + + switch (incident.type) { + case "route_leak": + routeLeaksTotal++; + if (detail.wouldHavePrevented) routeLeaksPrevented++; + break; + case "hijack": + hijacksTotal++; + if (detail.wouldHavePrevented) hijacksPrevented++; + break; + case "misconfiguration": + misconfigTotal++; + if (detail.wouldHavePrevented) misconfigPrevented++; + break; + } + } + + const totalPrevented = details.filter((d) => d.wouldHavePrevented).length; + const preventionRate = + bgpIncidents.length > 0 + ? Math.round((totalPrevented / bgpIncidents.length) * 100) + : 0; + + return { + totalIncidents: bgpIncidents.length, + wouldHavePrevented: totalPrevented, + preventionRate, + details, + byType: { + routeLeaks: { total: routeLeaksTotal, prevented: routeLeaksPrevented }, + hijacks: { total: hijacksTotal, prevented: hijacksPrevented }, + misconfigurations: { total: misconfigTotal, prevented: misconfigPrevented }, + }, + targetAsn, + simulatedAt: new Date().toISOString(), + }; +} + +/** + * Simulate a single incident against ASPA objects. + * + * @param incident - The BGP incident to simulate + * @param targetAsn - The ASN we are simulating deployment for + * @param aspaObjects - The ASPA objects to validate against + * @returns Simulation detail for this incident + */ +function simulateIncident( + incident: BGPIncident, + targetAsn: number, + aspaObjects: ReadonlyMap +): SimulationDetail { + const path = incident.asPath; + const result = validatePath(path, aspaObjects, "upstream"); + + if (result.status === "invalid" && result.leakDetected) { + return { + incident, + wouldHavePrevented: true, + aspaStatus: result.status, + explanation: + `ASPA validation would have flagged this path as invalid. ` + + `AS${result.leakingAsn ?? incident.offendingAsn} is not an authorized provider ` + + `in the ASPA object, so the route leak would have been detected and the path rejected.`, + detectingAsn: targetAsn, + }; + } + + if (result.status === "invalid") { + return { + incident, + wouldHavePrevented: true, + aspaStatus: result.status, + explanation: + `ASPA validation detected an unauthorized hop in the path. ` + + `The path would have been rejected, preventing propagation of this incident.`, + detectingAsn: targetAsn, + }; + } + + if (result.status === "unknown") { + return { + incident, + wouldHavePrevented: false, + aspaStatus: result.status, + explanation: + `ASPA validation returned "unknown" because one or more ASNs in the path ` + + `do not have ASPA objects. Broader ASPA deployment would be needed to detect this incident. ` + + `Current ASPA coverage confidence: ${Math.round(result.confidence * 100)}%.`, + }; + } + + return { + incident, + wouldHavePrevented: false, + aspaStatus: result.status, + explanation: + result.status === "valid" + ? `The path appears valid under ASPA. This incident type (${incident.type}) ` + + `may not be preventable by ASPA alone, or the ASPA objects may need updating.` + : `The path is unverifiable — insufficient data for ASPA validation.`, + }; +} + +/** + * Infer legitimate providers for an ASN from incident data. + * + * Looks at incidents where the target ASN is the victim and identifies + * ASNs that appear as legitimate upstream providers (not the offending ASN). + * + * @param targetAsn - The ASN to infer providers for + * @param incidents - Historical incident data + * @returns Array of inferred provider ASNs + */ +function inferProvidersFromIncidents( + targetAsn: number, + incidents: ReadonlyArray +): ReadonlyArray { + const providerSet = new Set(); + + for (const incident of incidents) { + const { asPath, offendingAsn } = incident; + + for (let i = 1; i < asPath.length; i++) { + if (asPath[i] === targetAsn && asPath[i - 1] !== offendingAsn) { + providerSet.add(asPath[i - 1]); + } + } + } + + return Array.from(providerSet); +} + +/** + * Calculate the aggregate prevention rate of ASPA across a set of incidents. + * + * A convenience function that runs validation on each incident's AS path + * and returns the percentage that would have been caught. + * + * @param incidents - BGP incidents to evaluate + * @param aspaObjects - ASPA objects to validate against + * @returns Prevention rate as a percentage (0-100) + * + * @example + * ```typescript + * const rate = calculatePreventionRate(recentIncidents, aspaObjects); + * console.log(`ASPA would prevent ${rate}% of incidents`); + * ``` + */ +export function calculatePreventionRate( + incidents: ReadonlyArray, + aspaObjects: ReadonlyMap +): number { + if (incidents.length === 0) return 0; + + let prevented = 0; + + for (const incident of incidents) { + const result = validatePath(incident.asPath, aspaObjects, "upstream"); + if (result.status === "invalid") { + prevented++; + } + } + + return Math.round((prevented / incidents.length) * 100); +} diff --git a/src/aspa/validator.ts b/src/aspa/validator.ts new file mode 100644 index 0000000..47dd181 --- /dev/null +++ b/src/aspa/validator.ts @@ -0,0 +1,332 @@ +/** + * @module aspa/validator + * RFC 9582 Section 6 — ASPA-based AS path validation algorithm. + * + * Implements the Autonomous System Provider Authorization (ASPA) path + * validation procedure as defined in RFC 9582. ASPA enables detection + * of route leaks and unauthorized path segments by verifying that each + * AS in a BGP path has authorized its upstream provider relationship. + * + * @see https://www.rfc-editor.org/rfc/rfc9582#section-6 + * + * @example + * ```typescript + * const aspaObjects = new Map(); + * aspaObjects.set(64501, { + * customerAsn: 64501, + * providers: [{ asn: 64500, afi: ["ipv4", "ipv6"] }], + * }); + * + * const result = validatePath( + * [13335, 64501, 64500, 174], + * aspaObjects, + * "upstream" + * ); + * console.log(result.status); // "valid" | "invalid" | "unknown" | "unverifiable" + * ``` + */ + +// ── Interfaces ────────────────────────────────────────── + +/** + * An ASPA object as registered in the RPKI. + * + * Maps a customer AS to its authorized upstream providers, + * optionally scoped to specific address families. + * + * @see RFC 9582 Section 3 — ASPA Profile + */ +export interface ASPAObject { + /** The customer AS that created this authorization */ + readonly customerAsn: number; + /** Authorized upstream provider ASNs with address family scope */ + readonly providers: ReadonlyArray<{ + readonly asn: number; + readonly afi: ReadonlyArray<"ipv4" | "ipv6">; + }>; +} + +/** + * Result of ASPA path validation. + * + * Contains the validation status, the analyzed path, any violations + * found, and whether a route leak was detected. + */ +export interface ASPAValidationResult { + /** Overall validation status per RFC 9582 Section 6 */ + readonly status: "valid" | "invalid" | "unknown" | "unverifiable"; + /** The AS path that was validated */ + readonly path: ReadonlyArray; + /** List of specific violations found in the path */ + readonly violations: ReadonlyArray; + /** Whether the path exhibits a route leak pattern */ + readonly leakDetected: boolean; + /** The ASN responsible for the leak, if detected */ + readonly leakingAsn?: number; + /** Confidence score from 0.0 to 1.0 based on ASPA coverage of the path */ + readonly confidence: number; +} + +/** + * A specific ASPA violation at a position in the AS path. + * + * Indicates that a hop in the path was not authorized by the + * customer's ASPA object. + */ +export interface ASPAViolation { + /** Zero-based position in the AS path where the violation occurs */ + readonly position: number; + /** The ASN at this position */ + readonly asn: number; + /** ASNs that are authorized providers for this ASN */ + readonly expectedProviders: ReadonlyArray; + /** The actual next-hop ASN in the path */ + readonly actualNextHop: number; + /** Human-readable explanation of the violation */ + readonly reason: string; +} + +// ── Helper Functions ──────────────────────────────────── + +/** + * Check whether `providerAsn` is an authorized provider of `customerAsn`. + * + * @param customerAsn - The customer ASN to check + * @param providerAsn - The candidate provider ASN + * @param aspaObjects - Map of all known ASPA objects + * @param afi - Address family to check ("ipv4" or "ipv6") + * @returns "provider" if authorized, "not-provider" if explicitly not listed, + * or "no-attestation" if the customer has no ASPA object + * + * @see RFC 9582 Section 6 — Verification of Provider Authorization + */ +function checkProviderAuthorization( + customerAsn: number, + providerAsn: number, + aspaObjects: ReadonlyMap, + afi: "ipv4" | "ipv6" = "ipv4" +): "provider" | "not-provider" | "no-attestation" { + const aspa = aspaObjects.get(customerAsn); + + if (!aspa) { + return "no-attestation"; + } + + const isAuthorized = aspa.providers.some( + (p) => p.asn === providerAsn && p.afi.includes(afi) + ); + + return isAuthorized ? "provider" : "not-provider"; +} + +/** + * Remove consecutive duplicate ASNs from a path (AS path prepending). + * + * BGP speakers may prepend their own ASN multiple times for traffic + * engineering. For ASPA validation, consecutive duplicates are collapsed. + * + * @param path - The raw AS path + * @returns The path with consecutive duplicates removed + */ +function deduplicatePath(path: ReadonlyArray): ReadonlyArray { + return path.filter((asn, index) => index === 0 || asn !== path[index - 1]); +} + +// ── Core Validation Functions ─────────────────────────── + +/** + * Validate an AS path in the upstream direction per RFC 9582 Section 6. + * + * Walks the path from the origin AS (rightmost) toward the validating AS + * (leftmost). For each pair (customer, provider), verifies that the + * customer has authorized the provider via an ASPA object. + * + * The upstream validation procedure (RFC 9582 Section 6): + * - If the path has 0 or 1 unique ASNs, the result is "valid". + * - Walk from index N-1 (origin) toward index 0. + * - At each hop, check if path[i] authorizes path[i-1] as its provider. + * - If any hop yields "not-provider", the path is "invalid". + * - If all hops yield "provider", the path is "valid". + * - Otherwise the path is "unknown". + * + * @param path - AS path to validate (leftmost = closest to validator) + * @param aspaObjects - Map of customer ASN to ASPA object + * @returns Validation result with status, violations, and confidence + */ +export function validateUpstream( + path: ReadonlyArray, + aspaObjects: ReadonlyMap +): ASPAValidationResult { + const dedupedPath = deduplicatePath(path); + + // Trivial paths are always valid + if (dedupedPath.length <= 1) { + return { + status: "valid", + path: [...dedupedPath], + violations: [], + leakDetected: false, + confidence: 1.0, + }; + } + + const violations: ASPAViolation[] = []; + let hasNoAttestation = false; + let coveredHops = 0; + const totalHops = dedupedPath.length - 1; + + // Walk from origin (rightmost) toward the validator (leftmost). + // path[i] is the customer; path[i-1] is the alleged provider. + for (let i = dedupedPath.length - 1; i >= 1; i--) { + const customerAsn = dedupedPath[i]; + const providerAsn = dedupedPath[i - 1]; + + const authResult = checkProviderAuthorization( + customerAsn, + providerAsn, + aspaObjects + ); + + if (authResult === "provider") { + coveredHops++; + } else if (authResult === "not-provider") { + coveredHops++; + const aspa = aspaObjects.get(customerAsn); + violations.push({ + position: i, + asn: customerAsn, + expectedProviders: aspa + ? aspa.providers.map((p) => p.asn) + : [], + actualNextHop: providerAsn, + reason: `AS${customerAsn} has an ASPA object but does not list AS${providerAsn} as an authorized provider. ` + + `This indicates a potential route leak or unauthorized path segment.`, + }); + } else { + hasNoAttestation = true; + } + } + + const confidence = totalHops > 0 ? coveredHops / totalHops : 1.0; + + // Determine overall status per RFC 9582 Section 6 + if (violations.length > 0) { + const leakingViolation = violations[0]; + return { + status: "invalid", + path: [...dedupedPath], + violations, + leakDetected: true, + leakingAsn: leakingViolation.asn, + confidence, + }; + } + + if (hasNoAttestation) { + return { + status: "unknown", + path: [...dedupedPath], + violations: [], + leakDetected: false, + confidence, + }; + } + + return { + status: "valid", + path: [...dedupedPath], + violations: [], + leakDetected: false, + confidence, + }; +} + +/** + * Validate an AS path in the downstream direction per RFC 9582 Section 6. + * + * Reverses the path and applies the upstream validation procedure. + * Downstream validation is used when the validating AS is receiving + * a route from a customer rather than a provider. + * + * Per RFC 9582, the downstream verification is the mirror image of upstream: + * - Reverse the path so the "origin" from the downstream perspective is leftmost. + * - Apply the same provider-authorization checks. + * + * @param path - AS path to validate (leftmost = closest to validator) + * @param aspaObjects - Map of customer ASN to ASPA object + * @returns Validation result with status, violations, and confidence + */ +export function validateDownstream( + path: ReadonlyArray, + aspaObjects: ReadonlyMap +): ASPAValidationResult { + // Downstream: reverse the path and apply upstream logic. + const reversedPath = [...path].reverse(); + const result = validateUpstream(reversedPath, aspaObjects); + + // Map violations back to original path positions + const originalLength = deduplicatePath(path).length; + const remappedViolations: ReadonlyArray = result.violations.map( + (v) => ({ + ...v, + position: originalLength - 1 - v.position, + }) + ); + + return { + ...result, + path: [...deduplicatePath(path)], + violations: remappedViolations, + }; +} + +/** + * Validate an AS path against ASPA objects. + * + * This is the main entry point for ASPA path validation. It dispatches + * to either upstream or downstream validation based on the direction + * parameter. + * + * @param path - The AS path to validate. Leftmost ASN is closest to the + * validating router; rightmost is the origin. + * @param aspaObjects - Map of customer ASN to its ASPA object + * @param direction - "upstream" when receiving from a provider, + * "downstream" when receiving from a customer + * @returns Full validation result including status, violations, leak + * detection, and confidence score + * + * @see RFC 9582 Section 6 — Procedure for Verifying the AS_PATH Attribute + * + * @example + * ```typescript + * // Upstream validation: AS174 -> AS13335 -> AS64501 (origin) + * const result = validatePath( + * [174, 13335, 64501], + * aspaObjects, + * "upstream" + * ); + * + * if (result.leakDetected) { + * console.log(`Route leak by AS${result.leakingAsn}`); + * } + * ``` + */ +export function validatePath( + path: ReadonlyArray, + aspaObjects: ReadonlyMap, + direction: "upstream" | "downstream" +): ASPAValidationResult { + if (path.length === 0) { + return { + status: "unverifiable", + path: [], + violations: [], + leakDetected: false, + confidence: 0, + }; + } + + return direction === "upstream" + ? validateUpstream(path, aspaObjects) + : validateDownstream(path, aspaObjects); +} diff --git a/src/cache/store.ts b/src/cache/store.ts new file mode 100644 index 0000000..8207ffe --- /dev/null +++ b/src/cache/store.ts @@ -0,0 +1,235 @@ +/** + * @module cache/store + * SQLite-backed cache for API responses. + * + * Caches responses from PeeringDB, RIPE Stat, and other sources + * to reduce API calls, improve response times, and enable offline use. + * Uses better-sqlite3 for synchronous, high-performance SQLite access. + */ + +import Database from "better-sqlite3"; +import type { DataSourceName, CacheEntry } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +interface CacheConfig { + readonly dbPath?: string; + readonly defaultTTLSeconds?: number; +} + +// ── Cache Store ────────────────────────────────────────── + +/** + * SQLite-backed cache store. + * + * Provides get/set/invalidate operations with TTL-based expiration. + * Each cached entry is tagged with its data source for selective invalidation. + * + * @example + * ```typescript + * const cache = createCacheStore({ dbPath: "./peercortex-cache.db" }); + * await cache.set("peeringdb:net:13335", networkData, "peeringdb", 3600); + * const cached = await cache.get("peeringdb:net:13335"); + * ``` + */ +export interface CacheStore { + /** Get a cached value by key. Returns null if not found or expired. */ + get(key: string): CacheEntry | null; + + /** Set a cached value with optional TTL override. */ + set( + key: string, + data: T, + source: DataSourceName, + ttlSeconds?: number + ): void; + + /** Invalidate a specific cache entry. */ + invalidate(key: string): void; + + /** Invalidate all entries from a specific data source. */ + invalidateSource(source: DataSourceName): void; + + /** Invalidate all expired entries. */ + cleanup(): number; + + /** Get cache statistics. */ + stats(): CacheStats; + + /** Clear the entire cache. */ + clear(): void; + + /** Close the database connection. */ + close(): void; +} + +/** Cache usage statistics */ +export interface CacheStats { + readonly totalEntries: number; + readonly expiredEntries: number; + readonly sizeBytes: number; + readonly bySource: Record; +} + +/** + * Create a new SQLite-backed cache store. + * + * @param config - Cache configuration + * @returns A configured cache store instance + */ +export function createCacheStore(config: CacheConfig = {}): CacheStore { + const dbPath = config.dbPath ?? "./peercortex-cache.db"; + const defaultTTLSeconds = config.defaultTTLSeconds ?? 3600; + + let db: Database.Database; + + try { + db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("synchronous = NORMAL"); + } catch (error) { + throw new PeerCortexError( + `Failed to open cache database: ${error instanceof Error ? error.message : "Unknown error"}`, + "CACHE_ERROR", + undefined, + error instanceof Error ? error : undefined + ); + } + + // Create the cache table + db.exec(` + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + source TEXT NOT NULL, + cached_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cache_source ON cache(source); + CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at); + `); + + // Prepare statements for performance + const getStmt = db.prepare( + "SELECT key, data, source, cached_at, expires_at FROM cache WHERE key = ? AND expires_at > ?" + ); + const setStmt = db.prepare( + "INSERT OR REPLACE INTO cache (key, data, source, cached_at, expires_at) VALUES (?, ?, ?, ?, ?)" + ); + const deleteStmt = db.prepare("DELETE FROM cache WHERE key = ?"); + const deleteSourceStmt = db.prepare("DELETE FROM cache WHERE source = ?"); + const cleanupStmt = db.prepare("DELETE FROM cache WHERE expires_at <= ?"); + const countStmt = db.prepare("SELECT COUNT(*) as count FROM cache"); + const expiredCountStmt = db.prepare( + "SELECT COUNT(*) as count FROM cache WHERE expires_at <= ?" + ); + const sourceCountStmt = db.prepare( + "SELECT source, COUNT(*) as count FROM cache GROUP BY source" + ); + + return { + get(key: string): CacheEntry | null { + const now = new Date().toISOString(); + const row = getStmt.get(key, now) as + | { + key: string; + data: string; + source: string; + cached_at: string; + expires_at: string; + } + | undefined; + + if (!row) return null; + + try { + return { + key: row.key, + data: JSON.parse(row.data) as T, + source: row.source as DataSourceName, + cachedAt: row.cached_at, + expiresAt: row.expires_at, + }; + } catch { + // Corrupted cache entry — delete it + deleteStmt.run(key); + return null; + } + }, + + set( + key: string, + data: T, + source: DataSourceName, + ttlSeconds?: number + ): void { + const now = new Date(); + const ttl = ttlSeconds ?? defaultTTLSeconds; + const expiresAt = new Date(now.getTime() + ttl * 1000); + + setStmt.run( + key, + JSON.stringify(data), + source, + now.toISOString(), + expiresAt.toISOString() + ); + }, + + invalidate(key: string): void { + deleteStmt.run(key); + }, + + invalidateSource(source: DataSourceName): void { + deleteSourceStmt.run(source); + }, + + cleanup(): number { + const now = new Date().toISOString(); + const result = cleanupStmt.run(now); + return result.changes; + }, + + stats(): CacheStats { + const now = new Date().toISOString(); + const total = (countStmt.get() as { count: number }).count; + const expired = (expiredCountStmt.get(now) as { count: number }).count; + const sources = sourceCountStmt.all() as ReadonlyArray<{ + source: string; + count: number; + }>; + + const bySource: Record = {}; + for (const row of sources) { + bySource[row.source] = row.count; + } + + // Get database file size + let sizeBytes = 0; + try { + const sizeResult = db + .prepare("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()") + .get() as { size: number } | undefined; + sizeBytes = sizeResult?.size ?? 0; + } catch { + // Ignore size calculation errors + } + + return { + totalEntries: total, + expiredEntries: expired, + sizeBytes, + bySource, + }; + }, + + clear(): void { + db.exec("DELETE FROM cache"); + }, + + close(): void { + db.close(); + }, + }; +} diff --git a/src/mcp-server/index.ts b/src/mcp-server/index.ts new file mode 100644 index 0000000..fd61cf8 --- /dev/null +++ b/src/mcp-server/index.ts @@ -0,0 +1,608 @@ +#!/usr/bin/env node + +/** + * @module mcp-server/index + * PeerCortex MCP Server — AI-Powered Network Intelligence Platform + * + * Exposes network intelligence tools via the Model Context Protocol (MCP), + * enabling AI assistants like Claude to query PeeringDB, analyze BGP data, + * monitor RPKI compliance, and find peering partners. + * + * @example Start the server: + * ```bash + * # Via stdio (for Claude Code / MCP clients) + * npx peercortex + * + * # Via SSE (for web clients) + * MCP_TRANSPORT=sse MCP_PORT=3100 npx peercortex + * ``` + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { asnLookupSchema, prefixLookupSchema, ixLookupSchema, handleASNLookup, handlePrefixLookup, handleIXLookup } from "./tools/lookup.js"; +import { peeringDiscoverSchema, peeringEmailSchema, handlePeeringDiscover, handlePeeringEmail } from "./tools/peering.js"; +import { bgpAnalysisSchema, bgpAnomalySchema, routeLeakSchema, handleBGPAnalysis, handleAnomalyDetection, handleRouteLeakDetection } from "./tools/bgp.js"; +import { rpkiValidateSchema, rpkiComplianceSchema, rpkiIXCoverageSchema, handleRPKIValidation, handleRPKICompliance, handleRPKIIXCoverage } from "./tools/rpki.js"; +import { networkCompareSchema, handleNetworkCompare } from "./tools/compare.js"; +import { reportGenerateSchema, handleReportGenerate } from "./tools/report.js"; + +// Latency tools +import { rttMeasurementSchema, tracerouteSchema, handleRTTMeasurement, handleTraceroute } from "./tools/latency.js"; + +// Transit tools +import { upstreamAnalysisSchema, transitDiversitySchema, peeringVsTransitSchema, handleUpstreamAnalysis, handleTransitDiversity, handlePeeringVsTransit } from "./tools/transit.js"; + +// Topology tools +import { asGraphSchema, submarineCableSchema, facilityAnalysisSchema, handleASGraph, handleSubmarineCables, handleFacilityAnalysis } from "./tools/topology.js"; + +// Traffic tools +import { ixTrafficSchema, ixComparisonSchema, portUtilizationSchema, handleIXTraffic, handleIXComparison, handlePortUtilization } from "./tools/traffic.js"; + +// Security tools +import { hijackDetectionSchema, routeLeakDetectionSchema, bogonCheckSchema, blacklistCheckSchema, handleHijackDetection, handleRouteLeakDetection as handleRouteLeakDetectionSecurity, handleBogonCheck, handleBlacklistCheck } from "./tools/security.js"; + +// DNS tools +import { reverseDnsSchema, delegationCheckSchema, whoisLookupSchema, handleReverseDns, handleDelegationCheck, handleWhoisLookup } from "./tools/dns.js"; + +// Atlas tools +import { createMeasurementSchema, getMeasurementResultsSchema, searchProbesSchema, handleCreateMeasurement, handleGetMeasurementResults, handleSearchProbes } from "./tools/atlas.js"; + +// ── Server Configuration ───────────────────────────────── + +const SERVER_NAME = "peercortex"; +const SERVER_VERSION = "0.1.0"; + +// ── Initialize MCP Server ──────────────────────────────── + +const server = new McpServer({ + name: SERVER_NAME, + version: SERVER_VERSION, +}); + +// ── Register Tools ─────────────────────────────────────── + +// Tool 1: Network Lookup +server.tool( + "lookup", + "Look up comprehensive information for an ASN, IP prefix, or Internet Exchange. " + + "Queries PeeringDB, RIPE Stat, bgp.he.net, IRR databases, and RPKI validators.", + { + type: asnLookupSchema.shape.asn._def.description + ? { asn: asnLookupSchema.shape.asn } + : asnLookupSchema.shape, + }, + async (params) => { + try { + const result = await handleASNLookup(params as { asn: string | number }); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } +); + +// Tool 2: Peering Partner Discovery +server.tool( + "peering", + "Find optimal peering partners for an ASN. Analyzes common IXs, facilities, " + + "peering policies, and network types. Can also draft peering request emails.", + peeringDiscoverSchema.shape, + async (params) => { + try { + const result = await handlePeeringDiscover( + params as { asn: string | number; ix?: string; policy?: "open" | "selective" | "restrictive" | "any"; limit?: number } + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } +); + +// Tool 3: BGP Analysis +server.tool( + "bgp", + "Analyze BGP routing for an ASN or prefix. Detects route leaks, BGP hijacks, " + + "MOAS conflicts, and path anomalies. Uses RIPE Stat, Route Views, and bgp.he.net.", + bgpAnalysisSchema.shape, + async (params) => { + try { + const result = await handleBGPAnalysis( + params as { resource: string; include_paths?: boolean; include_anomalies?: boolean; time_range?: string } + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } +); + +// Tool 4: RPKI Monitoring +server.tool( + "rpki", + "RPKI validation and compliance monitoring. Validate prefix-origin pairs, " + + "generate compliance reports, and analyze RPKI coverage at Internet Exchanges.", + rpkiComplianceSchema.shape, + async (params) => { + try { + const result = await handleRPKICompliance( + params as { asn: string | number; include_recommendations?: boolean } + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } +); + +// Tool 5: Network Comparison +server.tool( + "compare", + "Compare two networks side by side. Shows common/unique IXs, facilities, " + + "peering policies, RPKI deployment, and identifies peering opportunities.", + networkCompareSchema.shape, + async (params) => { + try { + const result = await handleNetworkCompare( + params as { asn1: string | number; asn2: string | number; include_ai_analysis?: boolean } + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } +); + +// Tool 6: Report Generation +server.tool( + "report", + "Generate comprehensive network analysis reports. Supports peering readiness, " + + "RPKI compliance, network comparison, BGP health, and IX analysis reports. " + + "Output in Markdown, JSON, or plain text.", + reportGenerateSchema.shape, + async (params) => { + try { + const result = await handleReportGenerate( + params as { + type: "peering_readiness" | "rpki_compliance" | "network_comparison" | "bgp_health" | "ix_analysis"; + asn?: string | number; + asn2?: string | number; + ix?: string; + format?: "markdown" | "json" | "text"; + } + ); + return { + content: [ + { + type: "text" as const, + text: result.format === "json" + ? JSON.stringify(result, null, 2) + : result.content || JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } +); + +// ── Latency Tools ──────────────────────────────────────── + +server.tool( + "measure_rtt", + "Measure round-trip time (RTT) to a target using RIPE Atlas probes distributed globally.", + rttMeasurementSchema.shape, + async (params) => { + try { + const result = await handleRTTMeasurement(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "traceroute", + "Run a traceroute to a target via RIPE Atlas, annotating each hop with ASN, hostname, and IXP identification.", + tracerouteSchema.shape, + async (params) => { + try { + const result = await handleTraceroute(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── Transit Tools ──────────────────────────────────────── + +server.tool( + "upstream_analysis", + "Analyze upstream transit providers for an ASN — identify providers, stability, and single-homed prefixes.", + upstreamAnalysisSchema.shape, + async (params) => { + try { + const result = await handleUpstreamAnalysis(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "transit_diversity", + "Assess transit diversity and resilience — identify single points of failure and geographic gaps.", + transitDiversitySchema.shape, + async (params) => { + try { + const result = await handleTransitDiversity(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "peering_vs_transit", + "Compare direct peering vs. transit for reaching a target ASN — cost, latency, and path analysis.", + peeringVsTransitSchema.shape, + async (params) => { + try { + const result = await handlePeeringVsTransit(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── Topology Tools ─────────────────────────────────────── + +server.tool( + "as_graph", + "Generate an AS-level topology graph showing providers, customers, and peers around a center ASN.", + asGraphSchema.shape, + async (params) => { + try { + const result = await handleASGraph(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "submarine_cables", + "Look up submarine cable information — capacity, owners, landing points, and regional connectivity.", + submarineCableSchema.shape, + async (params) => { + try { + const result = await handleSubmarineCables(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "facility_analysis", + "Analyze facility/colocation presence for an ASN and find interconnection opportunities with a target.", + facilityAnalysisSchema.shape, + async (params) => { + try { + const result = await handleFacilityAnalysis(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── Traffic Tools ──────────────────────────────────────── + +server.tool( + "ix_traffic", + "Get traffic statistics and trends for an Internet Exchange — peak, average, growth, and history.", + ixTrafficSchema.shape, + async (params) => { + try { + const result = await handleIXTraffic(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "ix_comparison", + "Compare traffic statistics across multiple Internet Exchanges side by side.", + ixComparisonSchema.shape, + async (params) => { + try { + const result = await handleIXComparison(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "port_utilization", + "Analyze port utilization for an ASN across its IX connections with upgrade recommendations.", + portUtilizationSchema.shape, + async (params) => { + try { + const result = await handlePortUtilization(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── Security Tools ─────────────────────────────────────── + +server.tool( + "hijack_detection", + "Detect active and historical BGP hijacks for a prefix using RPKI ROV and MOAS analysis.", + hijackDetectionSchema.shape, + async (params) => { + try { + const result = await handleHijackDetection(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "route_leak_detection_aspa", + "Detect route leaks using ASPA validation — identifies unauthorized route propagation via bgproutes.io.", + routeLeakDetectionSchema.shape, + async (params) => { + try { + const result = await handleRouteLeakDetectionSecurity(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "bogon_check", + "Check for bogon prefix announcements and bogon ASNs in routing paths.", + bogonCheckSchema.shape, + async (params) => { + try { + const result = await handleBogonCheck(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "blacklist_check", + "Check an IP, prefix, or ASN against known blacklists and reputation databases.", + blacklistCheckSchema.shape, + async (params) => { + try { + const result = await handleBlacklistCheck(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── DNS Tools ──────────────────────────────────────────── + +server.tool( + "reverse_dns", + "Perform reverse DNS lookups for IP addresses with optional forward-confirmed verification.", + reverseDnsSchema.shape, + async (params) => { + try { + const result = await handleReverseDns(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "delegation_check", + "Check DNS delegation for a domain — nameservers, DNSSEC, glue records, and issues.", + delegationCheckSchema.shape, + async (params) => { + try { + const result = await handleDelegationCheck(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "whois_lookup", + "Perform a WHOIS lookup for an IP address, ASN, or domain.", + whoisLookupSchema.shape, + async (params) => { + try { + const result = await handleWhoisLookup(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── Atlas Tools ────────────────────────────────────────── + +server.tool( + "atlas_create_measurement", + "Create a new RIPE Atlas measurement (ping, traceroute, DNS, SSL, NTP, HTTP).", + createMeasurementSchema.shape, + async (params) => { + try { + const result = await handleCreateMeasurement(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "atlas_get_results", + "Get results for a RIPE Atlas measurement with summary statistics.", + getMeasurementResultsSchema.shape, + async (params) => { + try { + const result = await handleGetMeasurementResults(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +server.tool( + "atlas_search_probes", + "Search for RIPE Atlas probes by ASN, country, prefix, or anchor status.", + searchProbesSchema.shape, + async (params) => { + try { + const result = await handleSearchProbes(params as Parameters[0]); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text" as const, text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }], isError: true }; + } + } +); + +// ── Start Server ───────────────────────────────────────── + +async function main(): Promise { + const transport = process.env.MCP_TRANSPORT ?? "stdio"; + + if (transport === "stdio") { + const stdioTransport = new StdioServerTransport(); + await server.connect(stdioTransport); + console.error(`PeerCortex MCP Server v${SERVER_VERSION} running on stdio`); + } else if (transport === "sse") { + // TODO: Implement SSE transport + // const port = parseInt(process.env.MCP_PORT ?? "3100", 10); + // const sseTransport = new SSEServerTransport({ port }); + // await server.connect(sseTransport); + console.error("SSE transport not yet implemented. Use stdio transport."); + process.exit(1); + } else { + console.error(`Unknown transport: ${transport}. Use 'stdio' or 'sse'.`); + process.exit(1); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/src/mcp-server/tools/aspa.ts b/src/mcp-server/tools/aspa.ts new file mode 100644 index 0000000..f623a2f --- /dev/null +++ b/src/mcp-server/tools/aspa.ts @@ -0,0 +1,563 @@ +/** + * @module mcp-server/tools/aspa + * MCP Tools for ASPA (Autonomous System Provider Authorization) intelligence. + * + * Exposes ASPA validation, analysis, generation, simulation, coverage, + * and leak detection capabilities through the Model Context Protocol. + * + * @see https://www.rfc-editor.org/rfc/rfc9582 + */ + +import { z } from "zod"; +import type { ASPAValidationResult } from "../../aspa/validator.js"; +import { validatePath } from "../../aspa/validator.js"; +import type { ASPAObject } from "../../aspa/validator.js"; +import { fetchASPAObjects } from "../../aspa/objects.js"; +import { detectProviders, generateASPAObject, generateRipeDbTemplate } from "../../aspa/generator.js"; +import type { BGPPath } from "../../aspa/generator.js"; +import { simulateASPADeployment } from "../../aspa/simulator.js"; +import type { BGPIncident } from "../../aspa/simulator.js"; +import { getASPACoverage, getASPACoverageByRegion, compareASPAAdoption } from "../../aspa/coverage.js"; +import { detectRouteLeak, analyzeLeaks } from "../../aspa/leak-detector.js"; +import type { BGPUpdate } from "../../aspa/leak-detector.js"; +import { parseASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for ASPA path validation */ +export const aspaValidateSchema = z.object({ + as_path: z + .array(z.number()) + .describe( + "AS path to validate, ordered left-to-right from validator to origin (e.g., [174, 13335, 64501])" + ), + direction: z + .enum(["upstream", "downstream"]) + .optional() + .default("upstream") + .describe("Validation direction: 'upstream' (from provider) or 'downstream' (from customer)"), +}); + +/** + * Input schema for full ASPA readiness analysis. + * + * @example + * ``` + * > Analyze ASPA readiness for AS13335 + * + * Returns: ASPA object status, detected providers, path validation + * results, and deployment recommendations for Cloudflare. + * ``` + */ +export const aspaAnalyzeSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to analyze ASPA readiness for (e.g., 13335 or 'AS13335')"), +}); + +/** + * Input schema for ASPA object generation. + * + * @example + * ``` + * > Generate an ASPA object for AS13335 + * + * Returns: RIPE DB-ready ASPA object template with detected + * upstream providers (AS174, AS3356, etc.) and submission instructions. + * ``` + */ +export const aspaGenerateSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to generate ASPA object for"), + maintainer: z + .string() + .optional() + .describe("RIPE DB maintainer handle (e.g., 'MNT-CLOUDFLARE')"), +}); + +/** + * Input schema for ASPA deployment simulation. + * + * @example + * ``` + * > What would ASPA have prevented in the last 30 days? + * + * Returns: Simulation showing how many of the recent BGP incidents + * (route leaks, hijacks) would have been prevented by ASPA deployment. + * ``` + */ +export const aspaSimulateSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to simulate ASPA deployment for"), +}); + +/** + * Input schema for ASPA coverage statistics. + * + * @example + * ``` + * > Show ASPA adoption at DE-CIX Frankfurt + * + * Returns: Number of DE-CIX participants with/without ASPA objects, + * adoption percentage, and top adopters. + * ``` + */ +export const aspaCoverageSchema = z.object({ + ixp_id: z + .number() + .optional() + .describe("PeeringDB IXP ID to scope analysis (e.g., 31 for DE-CIX Frankfurt)"), + region: z + .string() + .optional() + .describe("Geographic region to scope analysis (e.g., 'Europe', 'North America')"), +}); + +/** + * Input schema for ASPA-based route leak detection. + * + * @example + * ``` + * > Detect route leaks using ASPA for 1.1.1.0/24 + * + * Returns: Recent BGP updates for the prefix analyzed against ASPA, + * with any detected leaks, severity, and leaking ASN. + * ``` + */ +export const aspaLeaksSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to detect route leaks for"), + hours: z + .number() + .optional() + .default(24) + .describe("Number of hours to look back (default: 24)"), +}); + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Validate an AS path against ASPA objects. + * + * Fetches ASPA objects for all ASNs in the path, then runs the + * RFC 9582 Section 6 validation algorithm. + * + * @example + * ``` + * > Is the path [174, 13335, 64501] ASPA-valid? + * + * Returns: + * { + * "status": "valid", + * "path": [174, 13335, 64501], + * "violations": [], + * "leakDetected": false, + * "confidence": 0.67 + * } + * ``` + */ +export async function handleASPAValidate( + input: z.infer +): Promise { + const aspaObjects = new Map(); + + // Fetch ASPA objects for all ASNs in the path + const uniqueAsns = [...new Set(input.as_path)]; + const fetchResults = await Promise.allSettled( + uniqueAsns.map(async (asn) => { + const objects = await fetchASPAObjects(asn); + if (objects.length > 0) { + aspaObjects.set(asn, objects[0]); + } + }) + ); + + // Log any fetch failures (non-blocking) + for (const result of fetchResults) { + if (result.status === "rejected") { + // Silently continue — missing ASPA objects result in "unknown" status + } + } + + return validatePath(input.as_path, aspaObjects, input.direction); +} + +/** + * Full ASPA readiness analysis for an ASN. + * + * Checks whether the ASN has registered ASPA objects, detects its + * upstream providers from BGP data, and provides deployment recommendations. + * + * @example + * ``` + * > Analyze ASPA readiness for AS13335 + * + * Returns: + * { + * "asn": 13335, + * "hasAspaObject": false, + * "detectedProviders": [ + * { "asn": 174, "name": "Cogent", "confidence": 0.95 }, + * { "asn": 3356, "name": "Lumen", "confidence": 0.90 } + * ], + * "recommendations": [ + * "Register ASPA object listing AS174 and AS3356 as providers", + * "Submit via RIPE DB at https://apps.db.ripe.net/db-web-ui/webupdates" + * ] + * } + * ``` + */ +export async function handleASPAAnalyze( + input: z.infer +): Promise<{ + asn: number; + hasAspaObject: boolean; + existingProviders: ReadonlyArray<{ asn: number; afi: ReadonlyArray }>; + detectedProviders: ReadonlyArray<{ + asn: number; + name: string; + confidence: number; + pathCount: number; + }>; + recommendations: ReadonlyArray; + generatedAt: string; +}> { + const asn = parseASN(input.asn); + + // Check for existing ASPA objects + let hasAspaObject = false; + let existingProviders: ReadonlyArray<{ asn: number; afi: ReadonlyArray }> = []; + + try { + const objects = await fetchASPAObjects(asn); + if (objects.length > 0) { + hasAspaObject = true; + existingProviders = objects[0].providers.map((p) => ({ + asn: p.asn, + afi: [...p.afi], + })); + } + } catch { + // Continue without existing objects + } + + // Detect providers from BGP data + // In a full implementation, this would query RIPE Stat for BGP paths. + // For now, return the analysis structure with detected providers from + // any existing ASPA objects. + const detectedProviders = existingProviders.map((p) => ({ + asn: p.asn, + name: `AS${p.asn}`, + confidence: 0.9, + pathCount: 0, + })); + + // Generate recommendations + const recommendations: string[] = []; + + if (!hasAspaObject) { + recommendations.push( + `Register an ASPA object for AS${asn} with your RIR to enable route leak prevention.` + ); + recommendations.push( + `Submit via RIPE DB at https://apps.db.ripe.net/db-web-ui/webupdates` + ); + recommendations.push( + `Use the peercortex_aspa_generate tool to create a ready-to-submit template.` + ); + } else { + recommendations.push( + `AS${asn} has an ASPA object with ${existingProviders.length} authorized provider(s).` + ); + recommendations.push( + `Verify the provider list is current — remove decommissioned providers and add new ones.` + ); + } + + recommendations.push( + `Encourage your upstream providers to also register ASPA objects for maximum protection.` + ); + recommendations.push( + `Enable ASPA-based filtering on your BGP sessions where supported by your router vendor.` + ); + + return { + asn, + hasAspaObject, + existingProviders, + detectedProviders, + recommendations, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Generate an ASPA object template for an ASN. + * + * Detects upstream providers from BGP path data and generates + * a RIPE DB-ready ASPA object template. + * + * @example + * ``` + * > Generate an ASPA object for AS13335 + * + * Returns: + * { + * "asn": 13335, + * "template": "aut-num: AS13335\nupstream: AS174\nupstream: AS3356\nmnt-by: MNT-CLOUDFLARE\nsource: RIPE", + * "detectedProviders": [{ "asn": 174, ... }, { "asn": 3356, ... }], + * "instructions": "Submit via RIPE DB..." + * } + * ``` + */ +export async function handleASPAGenerate( + input: z.infer +): Promise<{ + asn: number; + object: string; + template: string; + detectedProviders: ReadonlyArray<{ + asn: number; + name: string; + confidence: number; + }>; + instructions: string; +}> { + const asn = parseASN(input.asn); + const maintainer = input.maintainer ?? `MNT-AS${asn}`; + + // Detect providers (in full implementation, fetch BGP paths from RIPE Stat) + // For now, try to infer from any existing ASPA objects + let providers: ReadonlyArray<{ + asn: number; + name: string; + confidence: number; + pathCount: number; + afi: ReadonlyArray<"ipv4" | "ipv6">; + }> = []; + + try { + const objects = await fetchASPAObjects(asn); + if (objects.length > 0) { + providers = objects[0].providers.map((p) => ({ + asn: p.asn, + name: `AS${p.asn}`, + confidence: 1.0, + pathCount: 0, + afi: [...p.afi], + })); + } + } catch { + // Continue with empty provider list + } + + const object = generateASPAObject(asn, providers); + const template = generateRipeDbTemplate(asn, providers, maintainer); + + return { + asn, + object, + template, + detectedProviders: providers.map((p) => ({ + asn: p.asn, + name: p.name, + confidence: p.confidence, + })), + instructions: + `To register this ASPA object:\n` + + `1. Review the detected providers and adjust as needed\n` + + `2. Go to https://apps.db.ripe.net/db-web-ui/webupdates\n` + + `3. Paste the template and submit\n` + + `4. Alternatively, email the template to auto-dbm@ripe.net\n` + + `\nNote: Your RIR must support ASPA objects. Check current support status.`, + }; +} + +/** + * Run a what-if ASPA deployment simulation. + * + * Simulates how ASPA would have affected recent BGP incidents + * if the target ASN had deployed ASPA objects. + * + * @example + * ``` + * > Simulate ASPA deployment for AS13335 + * + * Returns: + * { + * "totalIncidents": 15, + * "wouldHavePrevented": 11, + * "preventionRate": 73, + * "details": [...] + * } + * ``` + */ +export async function handleASPASimulate( + input: z.infer +): Promise<{ + asn: number; + totalIncidents: number; + wouldHavePrevented: number; + preventionRate: number; + byType: { + routeLeaks: { total: number; prevented: number }; + hijacks: { total: number; prevented: number }; + misconfigurations: { total: number; prevented: number }; + }; + simulatedAt: string; + note: string; +}> { + const asn = parseASN(input.asn); + + // In a full implementation, this would fetch recent BGP incidents + // from data sources. For now, return the simulation structure. + const incidents: BGPIncident[] = []; + + const result = simulateASPADeployment(asn, incidents); + + return { + asn, + totalIncidents: result.totalIncidents, + wouldHavePrevented: result.wouldHavePrevented, + preventionRate: result.preventionRate, + byType: result.byType, + simulatedAt: result.simulatedAt, + note: + `Simulation based on publicly available BGP incident data. ` + + `Connect additional data sources (e.g., BGPStream, GRIP) for more comprehensive results.`, + }; +} + +/** + * Get ASPA adoption/coverage statistics. + * + * Returns ASPA deployment statistics globally, per IXP, or per region. + * + * @example + * ``` + * > Show ASPA adoption at DE-CIX Frankfurt + * + * Returns: + * { + * "scope": "DE-CIX Frankfurt", + * "total": 950, + * "withAspa": 85, + * "withoutAspa": 865, + * "percentage": 8.9, + * "topAdopters": [{ "asn": 13335, "name": "Cloudflare" }, ...] + * } + * ``` + */ +export async function handleASPACoverage( + input: z.infer +): Promise<{ + total: number; + withAspa: number; + withoutAspa: number; + percentage: number; + topAdopters: ReadonlyArray<{ asn: number; name: string }>; + scope: string; + generatedAt: string; +}> { + if (input.region) { + return getASPACoverageByRegion(input.region); + } + + return getASPACoverage(input.ixp_id); +} + +/** + * Detect route leaks using ASPA validation. + * + * Analyzes recent BGP updates for an ASN and flags any updates + * where ASPA validation indicates a route leak. + * + * @example + * ``` + * > Detect route leaks using ASPA for AS13335 + * + * Returns: + * { + * "asn": 13335, + * "timeRange": { "start": "2026-03-25T12:00:00Z", "end": "2026-03-26T12:00:00Z" }, + * "totalLeaks": 3, + * "bySeverity": { "critical": 1, "high": 1, "medium": 1 }, + * "leaks": [ + * { + * "prefix": "1.1.1.0/24", + * "leakingAsn": 64501, + * "severity": "critical", + * "description": "Route leak detected for 1.1.1.0/24: AS64501 caused an accidental route leak." + * } + * ] + * } + * ``` + */ +export async function handleASPALeaks( + input: z.infer +): Promise<{ + asn: number; + timeRange: { start: string; end: string }; + totalLeaks: number; + bySeverity: { critical: number; high: number; medium: number }; + byType: Partial>; + leaks: ReadonlyArray<{ + prefix: string; + leakingAsn: number; + severity: string; + leakType: string; + timestamp: string; + description: string; + }>; + topLeakers: ReadonlyArray<{ + asn: number; + count: number; + lastSeen: string; + }>; + generatedAt: string; +}> { + const asn = parseASN(input.asn); + const hours = input.hours; + + const end = new Date(); + const start = new Date(end.getTime() - hours * 3600 * 1000); + + const timeRange = { + start: start.toISOString(), + end: end.toISOString(), + }; + + // Fetch ASPA objects for the target ASN and its neighbors + const aspaObjects = new Map(); + try { + const objects = await fetchASPAObjects(asn); + if (objects.length > 0) { + aspaObjects.set(asn, objects[0]); + } + } catch { + // Continue without ASPA objects + } + + const report = await analyzeLeaks(asn, timeRange, aspaObjects); + + return { + asn, + timeRange, + totalLeaks: report.totalLeaks, + bySeverity: report.bySeverity, + byType: report.byType, + leaks: report.leaks.map((l) => ({ + prefix: l.prefix, + leakingAsn: l.leakingAsn, + severity: l.severity, + leakType: l.leakType, + timestamp: l.timestamp.toISOString(), + description: l.description, + })), + topLeakers: report.topLeakers, + generatedAt: report.generatedAt, + }; +} diff --git a/src/mcp-server/tools/atlas.ts b/src/mcp-server/tools/atlas.ts new file mode 100644 index 0000000..c26ff5f --- /dev/null +++ b/src/mcp-server/tools/atlas.ts @@ -0,0 +1,234 @@ +/** + * @module mcp-server/tools/atlas + * MCP Tool: RIPE Atlas measurement management. + * + * Provides tools for creating, managing, and interpreting RIPE Atlas + * network measurements. Wraps the Atlas source client with MCP-friendly + * schemas and result formatting. + */ + +import { z } from "zod"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for creating an Atlas measurement */ +export const createMeasurementSchema = z.object({ + type: z + .enum(["ping", "traceroute", "dns", "sslcert", "ntp", "http"]) + .describe("Measurement type"), + target: z + .string() + .describe("Target IP address or hostname"), + description: z + .string() + .optional() + .describe("Human-readable measurement description"), + af: z + .union([z.literal(4), z.literal(6)]) + .optional() + .default(4) + .describe("Address family (4 or 6)"), + probeCount: z + .number() + .optional() + .default(10) + .describe("Number of probes to use"), + probeSelection: z + .object({ + type: z + .enum(["area", "country", "prefix", "asn", "probes"]) + .describe("Probe selection method"), + value: z + .string() + .describe("Selection value (e.g., 'WW', 'DE', '1.1.1.0/24', '13335')"), + }) + .optional() + .describe("Probe selection criteria"), +}); + +/** Input schema for getting measurement results */ +export const getMeasurementResultsSchema = z.object({ + measurementId: z + .number() + .describe("RIPE Atlas measurement ID"), + format: z + .enum(["raw", "summary", "detailed"]) + .optional() + .default("summary") + .describe("Result format (default: summary)"), +}); + +/** Input schema for searching probes */ +export const searchProbesSchema = z.object({ + asn: z + .number() + .optional() + .describe("Filter probes by ASN"), + country: z + .string() + .optional() + .describe("Filter probes by country code (e.g., 'DE')"), + prefix: z + .string() + .optional() + .describe("Filter probes by IP prefix"), + isAnchor: z + .boolean() + .optional() + .describe("Filter for anchors only"), + limit: z + .number() + .optional() + .default(25) + .describe("Maximum number of results (default: 25)"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** Created measurement info */ +export interface MeasurementCreated { + readonly measurementId: number; + readonly type: string; + readonly target: string; + readonly status: string; + readonly probesRequested: number; + readonly atlasUrl: string; +} + +/** Measurement result summary */ +export interface MeasurementResultSummary { + readonly measurementId: number; + readonly type: string; + readonly target: string; + readonly status: string; + readonly probesUsed: number; + readonly results: { + readonly rttStats?: { + readonly avgMs: number; + readonly minMs: number; + readonly maxMs: number; + readonly medianMs: number; + readonly stdDevMs: number; + }; + readonly tracerouteStats?: { + readonly avgHops: number; + readonly minHops: number; + readonly maxHops: number; + readonly uniquePaths: number; + }; + readonly dnsStats?: { + readonly avgResponseMs: number; + readonly successRate: number; + readonly answers: number; + }; + }; + readonly atlasUrl: string; +} + +/** Probe search result */ +export interface ProbeSearchResult { + readonly probes: ReadonlyArray<{ + readonly id: number; + readonly asnV4: number; + readonly asnV6: number; + readonly country: string; + readonly city: string | null; + readonly status: string; + readonly isAnchor: boolean; + readonly tags: ReadonlyArray; + }>; + readonly totalCount: number; + readonly returnedCount: number; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Create a new RIPE Atlas measurement. + * + * Requires a RIPE Atlas API key (set via RIPE_ATLAS_API_KEY env var). + * Creates a one-off measurement by default. + * + * @param input - Validated measurement parameters + * @returns Created measurement info with Atlas URL + * + * @example + * ``` + * > Run a traceroute from 20 probes in Germany to 1.1.1.1 + * + * Creates: Traceroute measurement with probe selection type "country", value "DE" + * Returns: Measurement ID and Atlas URL for monitoring + * ``` + */ +export async function handleCreateMeasurement( + input: z.infer +): Promise { + // TODO: Create measurement via RIPE Atlas client + // TODO: Map probe selection criteria + // TODO: Return measurement ID and tracking URL + + const _input = input; + + return { + measurementId: 0, // TODO: Real measurement ID + type: input.type, + target: input.target, + status: "Specified", + probesRequested: input.probeCount ?? 10, + atlasUrl: `https://atlas.ripe.net/measurements/0/`, // TODO: Real URL + }; +} + +/** + * Get results for a RIPE Atlas measurement. + * + * Fetches and summarizes measurement results in the requested format. + * + * @param input - Validated results query + * @returns Measurement result summary + */ +export async function handleGetMeasurementResults( + input: z.infer +): Promise { + // TODO: Fetch measurement metadata from Atlas client + // TODO: Fetch results and aggregate based on measurement type + // TODO: Calculate summary statistics + + return { + measurementId: input.measurementId, + type: "", // TODO: From measurement metadata + target: "", // TODO: From measurement metadata + status: "", // TODO: From measurement metadata + probesUsed: 0, + results: {}, + atlasUrl: `https://atlas.ripe.net/measurements/${input.measurementId}/`, + }; +} + +/** + * Search for RIPE Atlas probes by various criteria. + * + * @param input - Validated search parameters + * @returns Matching probes with metadata + * + * @example + * ``` + * > Find Atlas anchors in Germany + * + * Returns: List of RIPE Atlas anchors with ASN, location, and status. + * ``` + */ +export async function handleSearchProbes( + input: z.infer +): Promise { + // TODO: Query RIPE Atlas probes API with filters + // TODO: Map results to simplified probe format + + const _input = input; + + return { + probes: [], + totalCount: 0, + returnedCount: 0, + }; +} diff --git a/src/mcp-server/tools/bgp.ts b/src/mcp-server/tools/bgp.ts new file mode 100644 index 0000000..8936c20 --- /dev/null +++ b/src/mcp-server/tools/bgp.ts @@ -0,0 +1,209 @@ +/** + * @module mcp-server/tools/bgp + * MCP Tool: BGP analysis and anomaly detection. + * + * Analyzes BGP routing data from RIPE Stat, Route Views, and bgp.he.net + * to detect route leaks, hijacks, MOAS conflicts, and other anomalies. + */ + +import { z } from "zod"; +import type { BGPAnomaly, BGPRoute } from "../../types/common.js"; +import type { BGPPathAnalysis } from "../../types/bgp.js"; +import { parseASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for BGP analysis */ +export const bgpAnalysisSchema = z.object({ + resource: z + .string() + .describe("ASN (e.g., 'AS13335') or prefix (e.g., '1.1.1.0/24') to analyze"), + include_paths: z + .boolean() + .optional() + .default(true) + .describe("Include AS path analysis"), + include_anomalies: z + .boolean() + .optional() + .default(true) + .describe("Run anomaly detection"), + time_range: z + .string() + .optional() + .describe("Time range for analysis (e.g., '24h', '7d', '30d')"), +}); + +/** Input schema for anomaly detection */ +export const bgpAnomalySchema = z.object({ + prefixes: z + .array(z.string()) + .describe("Prefixes to monitor for anomalies"), + severity_threshold: z + .enum(["critical", "high", "medium", "low", "info"]) + .optional() + .default("medium") + .describe("Minimum severity level to report"), + lookback: z + .string() + .optional() + .default("24h") + .describe("How far back to check (e.g., '1h', '24h', '7d')"), +}); + +/** Input schema for route leak detection */ +export const routeLeakSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to check for route leaks involving their prefixes"), + lookback: z + .string() + .optional() + .default("7d") + .describe("How far back to check"), +}); + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Perform comprehensive BGP analysis for a resource. + * + * Combines data from RIPE Stat, Route Views, and bgp.he.net to provide + * a complete picture of the routing state for an ASN or prefix. + * + * @example + * ``` + * > Are there any BGP anomalies for 185.1.0.0/24 right now? + * + * Returns: Path analysis, visibility report, detected anomalies, + * and AI-generated assessment. + * ``` + */ +export async function handleBGPAnalysis( + input: z.infer +): Promise<{ + resource: string; + pathAnalysis?: BGPPathAnalysis; + anomalies: ReadonlyArray; + routes: ReadonlyArray; + aiAnalysis: string; +}> { + // TODO: Implementation steps: + // 1. Determine if resource is ASN or prefix + // 2. Query RIPE Stat for BGP state and updates + // 3. Query Route Views for path diversity + // 4. Query bgp.he.net for peer/upstream info + // 5. Run anomaly detection if requested + // 6. Use AI to generate comprehensive analysis + + return { + resource: input.resource, + pathAnalysis: undefined, // TODO: From Route Views client + anomalies: [], // TODO: From anomaly detection + routes: [], // TODO: From RIPE Stat BGP state + aiAnalysis: "", // TODO: AI-generated analysis + }; +} + +/** + * Detect BGP anomalies for a set of prefixes. + * + * Checks for: + * - Route leaks (unexpected transit) + * - BGP hijacks (unauthorized origin) + * - MOAS conflicts (multiple origins) + * - RPKI-invalid routes + * - Path anomalies (unusual AS paths) + * + * @example + * ``` + * > Show me all route leaks involving my prefixes in the last 7 days + * ``` + */ +export async function handleAnomalyDetection( + input: z.infer +): Promise> { + // TODO: Implementation steps: + // 1. For each prefix, get BGP updates from RIPE Stat + // 2. Check for MOAS (multiple origin AS) events + // 3. Validate each route against RPKI + // 4. Analyze AS paths for leak patterns: + // - Unexpected AS in path (potential leak) + // - New origin AS (potential hijack) + // - Abnormally long path (potential leak) + // - More-specific prefix announcement (potential hijack) + // 5. Filter by severity threshold + // 6. Use AI to assess severity and recommend actions + + const severityOrder: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, + info: 0, + }; + + const threshold = severityOrder[input.severity_threshold] ?? 2; + + // TODO: Collect anomalies from analysis + const anomalies: BGPAnomaly[] = []; + + // Filter by severity threshold + return anomalies.filter( + (a) => (severityOrder[a.severity] ?? 0) >= threshold + ); +} + +/** + * Detect route leaks involving an ASN's prefixes. + * + * Analyzes BGP updates to find instances where an ASN's prefixes + * were announced by unexpected paths, indicating potential route leaks. + */ +export async function handleRouteLeakDetection( + input: z.infer +): Promise<{ + asn: number; + leaks: ReadonlyArray; + summary: string; +}> { + const asn = parseASN(input.asn); + + // TODO: Implementation steps: + // 1. Get all announced prefixes for the ASN + // 2. For each prefix, check BGP updates in the lookback period + // 3. Identify routes with unexpected AS paths + // 4. Cross-reference with known upstreams from bgp.he.net + // 5. Generate summary with AI + + return { + asn, + leaks: [], + summary: "", // TODO: AI-generated summary + }; +} + +/** + * Parse a time range string into milliseconds. + * + * @param range - Time range string (e.g., "1h", "24h", "7d", "30d") + * @returns Duration in milliseconds + */ +export function parseTimeRange(range: string): number { + const match = range.match(/^(\d+)(h|d|m|w)$/); + if (!match) { + throw new Error(`Invalid time range: ${range}. Use format like '24h', '7d', '30d'`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + const multipliers: Record = { + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + }; + + return value * (multipliers[unit] ?? 0); +} diff --git a/src/mcp-server/tools/compare.ts b/src/mcp-server/tools/compare.ts new file mode 100644 index 0000000..b8638a4 --- /dev/null +++ b/src/mcp-server/tools/compare.ts @@ -0,0 +1,138 @@ +/** + * @module mcp-server/tools/compare + * MCP Tool: Network comparison — side-by-side analysis of two ASNs. + * + * Compares networks across multiple dimensions: size, IX presence, + * peering policy, RPKI deployment, and geographic coverage. + */ + +import { z } from "zod"; +import { parseASN } from "../../types/common.js"; +import type { PeeringPolicy, NetworkScope, NetworkType } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for network comparison */ +export const networkCompareSchema = z.object({ + asn1: z + .union([z.string(), z.number()]) + .describe("First ASN to compare (e.g., 13335 or 'AS13335')"), + asn2: z + .union([z.string(), z.number()]) + .describe("Second ASN to compare"), + include_ai_analysis: z + .boolean() + .optional() + .default(true) + .describe("Include AI-generated comparison narrative"), +}); + +// ── Types ──────────────────────────────────────────────── + +/** Network comparison result */ +export interface NetworkComparison { + readonly network1: NetworkSummary; + readonly network2: NetworkSummary; + readonly commonIXs: ReadonlyArray; + readonly commonFacilities: ReadonlyArray; + readonly uniqueIXs1: ReadonlyArray; + readonly uniqueIXs2: ReadonlyArray; + readonly peeringPotential: { + readonly score: number; + readonly canPeerAt: ReadonlyArray; + readonly recommendation: string; + }; + readonly aiAnalysis?: string; +} + +/** Summary of a single network for comparison */ +export interface NetworkSummary { + readonly asn: number; + readonly name: string; + readonly networkType: NetworkType; + readonly peeringPolicy: PeeringPolicy; + readonly scope: NetworkScope; + readonly prefixCount: { readonly v4: number; readonly v6: number }; + readonly ixCount: number; + readonly facilityCount: number; + readonly rpkiCoverage: number; + readonly ixList: ReadonlyArray; + readonly facilityList: ReadonlyArray; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Compare two networks side by side. + * + * Fetches data for both networks from PeeringDB, RIPE Stat, and RPKI, + * then generates a structured comparison with overlap analysis. + * + * @example + * ``` + * > Compare AS13335 and AS32934 — where do they peer? + * + * Returns: Side-by-side metrics, common/unique IXs, common facilities, + * peering potential score, and AI analysis. + * ``` + */ +export async function handleNetworkCompare( + input: z.infer +): Promise { + const asn1 = parseASN(input.asn1); + const asn2 = parseASN(input.asn2); + + // TODO: Implementation steps: + // 1. Fetch both networks from PeeringDB (parallel) + // 2. Get IX connections for both (parallel) + // 3. Get RPKI coverage for both (parallel) + // 4. Calculate common and unique IXs/facilities + // 5. Score peering potential + // 6. If include_ai_analysis, generate narrative comparison + + const emptySummary = (asn: number): NetworkSummary => ({ + asn, + name: "", // TODO: From PeeringDB + networkType: "Content", + peeringPolicy: "open", + scope: "Global", + prefixCount: { v4: 0, v6: 0 }, + ixCount: 0, + facilityCount: 0, + rpkiCoverage: 0, + ixList: [], + facilityList: [], + }); + + return { + network1: emptySummary(asn1), + network2: emptySummary(asn2), + commonIXs: [], // TODO: Compute intersection + commonFacilities: [], + uniqueIXs1: [], + uniqueIXs2: [], + peeringPotential: { + score: 0, + canPeerAt: [], + recommendation: "", + }, + aiAnalysis: input.include_ai_analysis ? "" : undefined, + }; +} + +/** + * Find the intersection of two arrays. + * Returns elements present in both arrays. + */ +export function findIntersection(a: ReadonlyArray, b: ReadonlyArray): ReadonlyArray { + const setB = new Set(b); + return a.filter((item) => setB.has(item)); +} + +/** + * Find elements in array A that are not in array B. + */ +export function findDifference(a: ReadonlyArray, b: ReadonlyArray): ReadonlyArray { + const setB = new Set(b); + return a.filter((item) => !setB.has(item)); +} diff --git a/src/mcp-server/tools/dns.ts b/src/mcp-server/tools/dns.ts new file mode 100644 index 0000000..bcaa460 --- /dev/null +++ b/src/mcp-server/tools/dns.ts @@ -0,0 +1,204 @@ +/** + * @module mcp-server/tools/dns + * MCP Tool: DNS intelligence — rDNS, delegation checks, WHOIS lookups. + * + * Provides DNS-related network intelligence using DNS-over-HTTPS resolution, + * delegation verification, and WHOIS queries. + */ + +import { z } from "zod"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for reverse DNS lookup */ +export const reverseDnsSchema = z.object({ + ips: z + .array(z.string()) + .min(1) + .max(100) + .describe("IP addresses to look up (max 100)"), + verifyForward: z + .boolean() + .optional() + .default(true) + .describe("Verify forward-confirmed reverse DNS (FCrDNS)"), +}); + +/** Input schema for delegation check */ +export const delegationCheckSchema = z.object({ + domain: z + .string() + .describe("Domain to check delegation for (e.g., 'cloudflare.com')"), + checkDnssec: z + .boolean() + .optional() + .default(true) + .describe("Include DNSSEC validation check"), +}); + +/** Input schema for WHOIS lookup */ +export const whoisLookupSchema = z.object({ + resource: z + .string() + .describe("IP address, prefix, ASN, or domain to look up"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** Reverse DNS result for a single IP */ +export interface ReverseDnsEntry { + readonly ip: string; + readonly hostname: string | null; + readonly forwardConfirmed: boolean; + readonly inferredOrg: string | null; +} + +/** Reverse DNS batch result */ +export interface ReverseDnsResult { + readonly entries: ReadonlyArray; + readonly resolvedCount: number; + readonly totalCount: number; + readonly fcrdnsPassCount: number; +} + +/** Delegation check result */ +export interface DelegationCheckResult { + readonly domain: string; + readonly nameservers: ReadonlyArray<{ + readonly hostname: string; + readonly ipv4: ReadonlyArray; + readonly ipv6: ReadonlyArray; + readonly responsive: boolean; + }>; + readonly dnssec: { + readonly enabled: boolean; + readonly valid: boolean; + readonly algorithm: string | null; + readonly dsRecordCount: number; + }; + readonly glueRecords: boolean; + readonly registrar: string | null; + readonly issues: ReadonlyArray; + readonly recommendations: ReadonlyArray; +} + +/** WHOIS result */ +export interface WhoisResult { + readonly resource: string; + readonly resourceType: "ip" | "asn" | "domain"; + readonly registrant: string; + readonly organization: string; + readonly country: string; + readonly registrar: string; + readonly creationDate: string; + readonly expirationDate: string; + readonly abuseContact: string; + readonly networkName: string | null; + readonly networkRange: string | null; + readonly rir: string | null; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Perform reverse DNS lookups for one or more IP addresses. + * + * Resolves each IP to its PTR record and optionally verifies that + * the resulting hostname resolves back to the original IP (FCrDNS). + * + * @param input - Validated lookup parameters + * @returns Batch reverse DNS results + * + * @example + * ``` + * > Reverse DNS for the hops in a traceroute to identify ASes + * + * Returns: Hostname and organization for each IP, with FCrDNS verification. + * ``` + */ +export async function handleReverseDns( + input: z.infer +): Promise { + // TODO: Use DNS client for batch reverse lookups + // TODO: Optionally verify FCrDNS for each result + // TODO: Infer organization from hostname patterns + + return { + entries: input.ips.map((ip) => ({ + ip, + hostname: null, // TODO: Resolve via DNS client + forwardConfirmed: false, + inferredOrg: null, + })), + resolvedCount: 0, + totalCount: input.ips.length, + fcrdnsPassCount: 0, + }; +} + +/** + * Check DNS delegation for a domain. + * + * Verifies nameserver configuration, DNSSEC status, glue records, + * and identifies potential delegation issues. + * + * @param input - Validated check parameters + * @returns Delegation check result with issues and recommendations + */ +export async function handleDelegationCheck( + input: z.infer +): Promise { + // TODO: Query NS records for the domain via DNS client + // TODO: Resolve each NS to IPs and check responsiveness + // TODO: Check DNSSEC (DS records, DNSKEY, signature validity) + // TODO: Verify glue records are present and correct + // TODO: Identify issues (lame delegation, missing glue, DNSSEC problems) + + return { + domain: input.domain, + nameservers: [], + dnssec: { + enabled: false, + valid: false, + algorithm: null, + dsRecordCount: 0, + }, + glueRecords: false, + registrar: null, + issues: [], + recommendations: [ + "Enable DNSSEC for improved domain security", + "Ensure at least two nameservers in different networks", + "Configure both IPv4 and IPv6 glue records", + ], + }; +} + +/** + * Perform a WHOIS lookup for an IP, ASN, or domain. + * + * @param input - Validated lookup parameters + * @returns Structured WHOIS information + */ +export async function handleWhoisLookup( + input: z.infer +): Promise { + // TODO: Use DNS client / node-whois for WHOIS query + // TODO: Detect resource type (IP, ASN, domain) + // TODO: Parse raw WHOIS text into structured fields + + return { + resource: input.resource, + resourceType: "domain", // TODO: Auto-detect + registrant: "", + organization: "", + country: "", + registrar: "", + creationDate: "", + expirationDate: "", + abuseContact: "", + networkName: null, + networkRange: null, + rir: null, + }; +} diff --git a/src/mcp-server/tools/latency.ts b/src/mcp-server/tools/latency.ts new file mode 100644 index 0000000..b0fcb7d --- /dev/null +++ b/src/mcp-server/tools/latency.ts @@ -0,0 +1,201 @@ +/** + * @module mcp-server/tools/latency + * MCP Tool: Latency measurements — RTT and traceroute via RIPE Atlas. + * + * Provides network latency analysis by creating RIPE Atlas measurements + * and interpreting the results. Supports both ping (RTT) and traceroute + * with AS-path correlation. + */ + +import { z } from "zod"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for RTT measurement */ +export const rttMeasurementSchema = z.object({ + target: z + .string() + .describe("Target IP address or hostname (e.g., '1.1.1.1', 'cloudflare.com')"), + sourceAsn: z + .number() + .optional() + .describe("Source ASN to select probes from (e.g., 13335)"), + sourceCountry: z + .string() + .optional() + .describe("Source country code to select probes from (e.g., 'DE', 'US')"), + probeCount: z + .number() + .optional() + .default(10) + .describe("Number of RIPE Atlas probes to use (default: 10)"), + af: z + .union([z.literal(4), z.literal(6)]) + .optional() + .default(4) + .describe("Address family: 4 for IPv4, 6 for IPv6"), +}); + +/** Input schema for traceroute */ +export const tracerouteSchema = z.object({ + target: z + .string() + .describe("Target IP address or hostname"), + sourceAsn: z + .number() + .optional() + .describe("Source ASN to select probes from"), + sourceCountry: z + .string() + .optional() + .describe("Source country code for probe selection"), + probeCount: z + .number() + .optional() + .default(5) + .describe("Number of RIPE Atlas probes (default: 5)"), + af: z + .union([z.literal(4), z.literal(6)]) + .optional() + .default(4) + .describe("Address family: 4 for IPv4, 6 for IPv6"), + resolveAsns: z + .boolean() + .optional() + .default(true) + .describe("Resolve each hop IP to its origin ASN"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** RTT measurement result */ +export interface RTTResult { + readonly target: string; + readonly probeResults: ReadonlyArray<{ + readonly probeId: number; + readonly probeAsn: number; + readonly probeCountry: string; + readonly avgRttMs: number; + readonly minRttMs: number; + readonly maxRttMs: number; + readonly packetLossPercent: number; + }>; + readonly summary: { + readonly globalAvgRttMs: number; + readonly globalMinRttMs: number; + readonly globalMaxRttMs: number; + readonly probesResponded: number; + readonly totalProbes: number; + }; + readonly measurementId: number; +} + +/** Traceroute hop with ASN annotation */ +export interface AnnotatedHop { + readonly hopNumber: number; + readonly ip: string | null; + readonly hostname: string | null; + readonly asn: number | null; + readonly asnName: string | null; + readonly rttMs: number | null; + readonly isIxp: boolean; +} + +/** Traceroute result */ +export interface TracerouteAnalysis { + readonly target: string; + readonly probeResults: ReadonlyArray<{ + readonly probeId: number; + readonly probeAsn: number; + readonly probeCountry: string; + readonly hops: ReadonlyArray; + readonly totalHops: number; + readonly asPathFromTrace: ReadonlyArray; + }>; + readonly summary: { + readonly avgHopCount: number; + readonly commonAsPath: ReadonlyArray; + readonly uniquePaths: number; + readonly ixpCrossings: ReadonlyArray; + }; + readonly measurementId: number; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Measure RTT (round-trip time) to a target using RIPE Atlas probes. + * + * Creates a one-off ping measurement from distributed probes and returns + * latency statistics per probe and a global summary. + * + * @param input - Validated measurement parameters + * @returns RTT results with per-probe and summary statistics + * + * @example + * ``` + * > What's the latency from Germany to Cloudflare's 1.1.1.1? + * + * Creates ping from DE probes to 1.1.1.1, returns avg/min/max RTT. + * ``` + */ +export async function handleRTTMeasurement( + input: z.infer +): Promise { + // TODO: Create RIPE Atlas ping measurement via atlas client + // TODO: Wait for measurement to complete (poll status) + // TODO: Fetch and aggregate results + // TODO: Calculate global summary stats + + return { + target: input.target, + probeResults: [], + summary: { + globalAvgRttMs: 0, + globalMinRttMs: 0, + globalMaxRttMs: 0, + probesResponded: 0, + totalProbes: input.probeCount ?? 10, + }, + measurementId: 0, // TODO: Real measurement ID from Atlas + }; +} + +/** + * Run a traceroute to a target using RIPE Atlas probes. + * + * Creates a one-off traceroute measurement, resolves each hop to its + * origin ASN, and identifies IXP crossings. + * + * @param input - Validated traceroute parameters + * @returns Annotated traceroute with AS path analysis + * + * @example + * ``` + * > Trace the path from AS32934 (Meta) to AS13335 (Cloudflare) + * + * Creates traceroute from Meta probes to Cloudflare, annotates each hop + * with ASN, hostname, and IXP identification. + * ``` + */ +export async function handleTraceroute( + input: z.infer +): Promise { + // TODO: Create RIPE Atlas traceroute measurement + // TODO: Wait for completion, fetch results + // TODO: Resolve each hop IP to ASN (via RIPE Stat or Team Cymru) + // TODO: Identify IXP crossings (match hop IPs against PeeringDB IX prefixes) + // TODO: Extract AS path from traceroute and compare with BGP AS path + + return { + target: input.target, + probeResults: [], + summary: { + avgHopCount: 0, + commonAsPath: [], + uniquePaths: 0, + ixpCrossings: [], + }, + measurementId: 0, // TODO: Real measurement ID from Atlas + }; +} diff --git a/src/mcp-server/tools/lookup.ts b/src/mcp-server/tools/lookup.ts new file mode 100644 index 0000000..208ec4b --- /dev/null +++ b/src/mcp-server/tools/lookup.ts @@ -0,0 +1,174 @@ +/** + * @module mcp-server/tools/lookup + * MCP Tool: ASN, Prefix, and IX lookups. + * + * Provides unified lookups across PeeringDB, RIPE Stat, bgp.he.net, IRR, + * and RPKI sources. Returns comprehensive network intelligence in a single call. + */ + +import { z } from "zod"; +import type { NetworkInfo, InternetExchange } from "../../types/common.js"; +import { parseASN, formatASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for ASN lookup */ +export const asnLookupSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to look up (e.g., 13335 or 'AS13335')"), + sources: z + .array( + z.enum([ + "peeringdb", + "ripe_stat", + "bgp_he", + "irr", + "rpki", + ]) + ) + .optional() + .describe("Data sources to query (default: all available)"), +}); + +/** Input schema for prefix lookup */ +export const prefixLookupSchema = z.object({ + prefix: z + .string() + .describe("IP prefix to look up (e.g., '1.1.1.0/24' or '2606:4700::/32')"), + include_bgp: z + .boolean() + .optional() + .default(true) + .describe("Include BGP routing data"), + include_rpki: z + .boolean() + .optional() + .default(true) + .describe("Include RPKI validation status"), +}); + +/** Input schema for IX lookup */ +export const ixLookupSchema = z.object({ + query: z + .string() + .describe( + "IX name or ID to search for (e.g., 'DE-CIX Frankfurt', 'AMS-IX', or PeeringDB IX ID)" + ), + include_participants: z + .boolean() + .optional() + .default(false) + .describe("Include list of participants"), +}); + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Look up comprehensive information for an ASN. + * + * Queries multiple data sources in parallel and merges results into a + * unified NetworkInfo object. + * + * @param input - Validated lookup parameters + * @returns Unified network information + * + * @example + * ``` + * > Give me the full picture for AS13335 + * + * Returns: Cloudflare info from PeeringDB + RIPE Stat + bgp.he.net + IRR + RPKI + * ``` + */ +export async function handleASNLookup( + input: z.infer +): Promise { + const asn = parseASN(input.asn); + + // TODO: Initialize source clients from server context + // TODO: Check cache before querying sources + // TODO: Query sources in parallel: + // - PeeringDB: network info, IX connections, facilities + // - RIPE Stat: AS overview, announced prefixes, visibility + // - bgp.he.net: peers, upstreams, downstreams + // - IRR: route objects, as-set + // - RPKI: ROA coverage, validation summary + + // Placeholder — will be populated from real source queries + const result: NetworkInfo = { + asn, + name: "", // TODO: From PeeringDB + aka: "", + description: "", // TODO: From RIPE Stat AS overview + website: "", + lookingGlass: "", + peeringPolicy: "open", + networkType: "Content", + scope: "Global", + prefixCount4: 0, // TODO: From PeeringDB + RIPE Stat + prefixCount6: 0, + ixCount: 0, + facilityCount: 0, + irr: { + asSet: "", // TODO: From IRR query + routeObjects: [], + }, + rpki: { + roaCount: 0, // TODO: From RPKI validation + coveragePercent: 0, + validPrefixes: 0, + invalidPrefixes: 0, + unknownPrefixes: 0, + }, + sources: ["peeringdb", "ripe_stat", "bgp_he", "irr", "rpki"], + lastUpdated: new Date().toISOString(), + }; + + // TODO: Cache the merged result + + return result; +} + +/** + * Look up information for an IP prefix. + * + * Returns origin ASN, BGP routing details, RPKI validation state, + * and IRR registration status. + */ +export async function handlePrefixLookup( + input: z.infer +): Promise<{ + prefix: string; + originASN: number; + bgp: { pathCount: number; upstreams: ReadonlyArray }; + rpki: { state: string; roaCount: number }; + irr: { registered: boolean; objects: ReadonlyArray }; +}> { + // TODO: Implement prefix lookup across sources + return { + prefix: input.prefix, + originASN: 0, + bgp: { pathCount: 0, upstreams: [] }, + rpki: { state: "unknown", roaCount: 0 }, + irr: { registered: false, objects: [] }, + }; +} + +/** + * Look up Internet Exchange information. + * + * Searches PeeringDB for IX details and optionally includes participant list. + */ +export async function handleIXLookup( + input: z.infer +): Promise<{ + ix: InternetExchange | null; + participants?: ReadonlyArray<{ asn: number; name: string; speed: number }>; +}> { + // TODO: Search PeeringDB for IX by name or ID + // TODO: Optionally fetch participant list + return { + ix: null, + participants: input.include_participants ? [] : undefined, + }; +} diff --git a/src/mcp-server/tools/peering.ts b/src/mcp-server/tools/peering.ts new file mode 100644 index 0000000..f052b6b --- /dev/null +++ b/src/mcp-server/tools/peering.ts @@ -0,0 +1,163 @@ +/** + * @module mcp-server/tools/peering + * MCP Tool: Peering partner discovery and matching. + * + * Uses PeeringDB data combined with AI analysis to find optimal peering + * partners based on common IXs, facilities, policy compatibility, and + * traffic patterns. + */ + +import { z } from "zod"; +import type { PeeringMatch, PeeringRequest } from "../../types/common.js"; +import { parseASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for peering partner discovery */ +export const peeringDiscoverSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("Your ASN to find peering partners for"), + ix: z + .string() + .optional() + .describe("Filter partners by IX name (e.g., 'DE-CIX Frankfurt')"), + policy: z + .enum(["open", "selective", "restrictive", "any"]) + .optional() + .default("any") + .describe("Filter by peering policy"), + limit: z + .number() + .optional() + .default(20) + .describe("Maximum number of results"), + network_type: z + .string() + .optional() + .describe("Filter by network type (e.g., 'Content', 'NSP', 'Enterprise')"), +}); + +/** Input schema for peering email generation */ +export const peeringEmailSchema = z.object({ + source_asn: z + .union([z.string(), z.number()]) + .describe("Your ASN"), + target_asn: z + .union([z.string(), z.number()]) + .describe("Target ASN to request peering with"), + ix: z + .string() + .describe("IX where you want to establish peering"), +}); + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Discover optimal peering partners for an ASN. + * + * Algorithm: + * 1. Get the source network's IX and facility list from PeeringDB + * 2. Find networks present at the same IXs/facilities + * 3. Filter by peering policy compatibility + * 4. Score and rank by match quality + * 5. Use AI to generate match reasoning + * + * @example + * ``` + * > Find peering partners for AS207613 at DE-CIX with open policy + * + * Returns: Ranked list of networks at DE-CIX with open peering policy, + * scored by relevance and mutual benefit. + * ``` + */ +export async function handlePeeringDiscover( + input: z.infer +): Promise> { + const asn = parseASN(input.asn); + + // TODO: Implementation steps: + // 1. Query PeeringDB for the source network's IX connections + // 2. If IX filter specified, get all participants at that IX + // Otherwise, get participants at all source IXs + // 3. Filter by peering policy if specified + // 4. Filter by network type if specified + // 5. Score each potential partner: + // - Number of common IXs (more = better) + // - Number of common facilities (co-location = bonus) + // - Policy compatibility (open = higher score) + // - Network type complementarity (content + eyeball = bonus) + // 6. Use AI to generate human-readable match reasoning + // 7. Sort by score descending, limit results + + // Placeholder return + return []; +} + +/** + * Generate a professional peering request email. + * + * Uses AI to draft a complete, ready-to-send peering request email + * based on the source and target network details from PeeringDB. + * + * @example + * ``` + * > Draft a peering request email to AS714 for DE-CIX Frankfurt + * + * Returns: Professional email with network details, mutual benefits, + * and technical parameters ready for sending. + * ``` + */ +export async function handlePeeringEmail( + input: z.infer +): Promise { + const sourceASN = parseASN(input.source_asn); + const targetASN = parseASN(input.target_asn); + + // TODO: Implementation steps: + // 1. Look up both networks on PeeringDB + // 2. Find common IXs between them + // 3. Get contact information for target network + // 4. Use AI to generate professional peering request email + // incorporating real network data + + return { + targetASN, + targetName: "", // TODO: From PeeringDB + ix: input.ix, + subject: `Peering Request: AS${sourceASN} <-> AS${targetASN} at ${input.ix}`, + body: "", // TODO: AI-generated email body + }; +} + +/** + * Score a potential peering partnership. + * + * @param commonIXCount - Number of common IXs + * @param commonFacilityCount - Number of common facilities + * @param policyMatch - Whether peering policies are compatible + * @param typeComplement - Whether network types are complementary + * @returns Score from 0 to 100 + */ +export function calculatePeeringScore( + commonIXCount: number, + commonFacilityCount: number, + policyMatch: boolean, + typeComplement: boolean +): number { + let score = 0; + + // Common IX presence (max 40 points) + score += Math.min(commonIXCount * 10, 40); + + // Common facility presence (max 20 points) + score += Math.min(commonFacilityCount * 10, 20); + + // Policy compatibility (20 points) + if (policyMatch) score += 20; + + // Network type complementarity (20 points) + if (typeComplement) score += 20; + + return Math.min(score, 100); +} diff --git a/src/mcp-server/tools/report.ts b/src/mcp-server/tools/report.ts new file mode 100644 index 0000000..932b544 --- /dev/null +++ b/src/mcp-server/tools/report.ts @@ -0,0 +1,167 @@ +/** + * @module mcp-server/tools/report + * MCP Tool: Generate comprehensive network analysis reports. + * + * Produces presentation-ready reports for peering readiness, RPKI compliance, + * BGP health, and network comparison. Suitable for NANOG, RIPE, DENOG meetings. + */ + +import { z } from "zod"; +import type { Report, ReportFormat, ReportType } from "../../types/common.js"; +import { parseASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for report generation */ +export const reportGenerateSchema = z.object({ + type: z + .enum([ + "peering_readiness", + "rpki_compliance", + "network_comparison", + "bgp_health", + "ix_analysis", + ]) + .describe("Type of report to generate"), + asn: z + .union([z.string(), z.number()]) + .optional() + .describe("Primary ASN for the report"), + asn2: z + .union([z.string(), z.number()]) + .optional() + .describe("Second ASN (for comparison reports)"), + ix: z + .string() + .optional() + .describe("IX name (for IX analysis reports)"), + format: z + .enum(["markdown", "json", "text"]) + .optional() + .default("markdown") + .describe("Output format"), +}); + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Generate a comprehensive network analysis report. + * + * Report types: + * - **peering_readiness**: Evaluates an ASN's readiness for peering + * - **rpki_compliance**: Full RPKI deployment status and recommendations + * - **network_comparison**: Side-by-side comparison of two networks + * - **bgp_health**: BGP routing health assessment + * - **ix_analysis**: Internet Exchange participation analysis + * + * @example + * ``` + * > Generate an RPKI compliance report for AS13335 + * + * Returns: Markdown report with coverage metrics, findings, + * recommendations, and data source attribution. + * ``` + */ +export async function handleReportGenerate( + input: z.infer +): Promise { + const reportType = input.type as ReportType; + const format = input.format as ReportFormat; + + // TODO: Implementation steps per report type: + // + // peering_readiness: + // 1. Get network info from PeeringDB + // 2. Analyze IX presence and facility coverage + // 3. Check peering policy and contact info + // 4. Assess RPKI readiness + // 5. Score overall peering readiness + // 6. AI-generate recommendations + // + // rpki_compliance: + // 1. Get all announced prefixes + // 2. Validate each against RPKI + // 3. Check IRR consistency + // 4. Generate coverage report + // 5. AI-generate remediation steps + // + // network_comparison: + // 1. Run full comparison tool + // 2. Format as detailed report + // 3. AI-generate narrative + // + // bgp_health: + // 1. Analyze all prefixes for anomalies + // 2. Check visibility and path diversity + // 3. Assess RPKI state + // 4. Check for recent incidents + // 5. AI-generate health assessment + // + // ix_analysis: + // 1. Get IX details from PeeringDB + // 2. Analyze participant mix + // 3. Check RPKI coverage + // 4. Identify peering opportunities + // 5. AI-generate IX analysis + + const title = getReportTitle(reportType, input); + + return { + type: reportType, + title, + format, + content: "", // TODO: Generate report content + metadata: { + generatedAt: new Date().toISOString(), + sources: ["peeringdb", "ripe_stat", "rpki"], + dataFreshness: "Data retrieved in real-time from source APIs", + }, + }; +} + +/** + * Generate a human-readable title for a report. + */ +function getReportTitle( + type: ReportType, + input: z.infer +): string { + const asnStr = input.asn ? `AS${parseASN(input.asn)}` : ""; + const asn2Str = input.asn2 ? `AS${parseASN(input.asn2)}` : ""; + + const titles: Record = { + peering_readiness: `Peering Readiness Report — ${asnStr}`, + rpki_compliance: `RPKI Compliance Report — ${asnStr}`, + network_comparison: `Network Comparison — ${asnStr} vs ${asn2Str}`, + bgp_health: `BGP Health Report — ${asnStr}`, + ix_analysis: `IX Analysis Report — ${input.ix ?? "Unknown IX"}`, + }; + + return titles[type] ?? `Network Report — ${asnStr}`; +} + +/** + * Format a report section as markdown. + */ +export function formatMarkdownSection( + heading: string, + level: number, + content: string +): string { + const prefix = "#".repeat(Math.min(level, 6)); + return `${prefix} ${heading}\n\n${content}\n\n`; +} + +/** + * Format a data table as markdown. + */ +export function formatMarkdownTable( + headers: ReadonlyArray, + rows: ReadonlyArray> +): string { + const headerRow = `| ${headers.join(" | ")} |`; + const separator = `| ${headers.map(() => "---").join(" | ")} |`; + const dataRows = rows.map((row) => `| ${row.join(" | ")} |`); + + return [headerRow, separator, ...dataRows].join("\n"); +} diff --git a/src/mcp-server/tools/rpki.ts b/src/mcp-server/tools/rpki.ts new file mode 100644 index 0000000..db48223 --- /dev/null +++ b/src/mcp-server/tools/rpki.ts @@ -0,0 +1,170 @@ +/** + * @module mcp-server/tools/rpki + * MCP Tool: RPKI validation and compliance monitoring. + * + * Validates prefix-origin pairs against ROAs, generates compliance reports, + * and identifies networks at IXs without RPKI coverage. + */ + +import { z } from "zod"; +import type { + RPKIValidation, + RPKIComplianceReport, +} from "../../types/common.js"; +import { parseASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for RPKI validation */ +export const rpkiValidateSchema = z.object({ + prefix: z.string().describe("Prefix to validate (e.g., '1.1.1.0/24')"), + origin_asn: z + .union([z.string(), z.number()]) + .describe("Origin ASN to validate against"), +}); + +/** Input schema for RPKI compliance report */ +export const rpkiComplianceSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to generate compliance report for"), + include_recommendations: z + .boolean() + .optional() + .default(true) + .describe("Include AI-generated improvement recommendations"), +}); + +/** Input schema for IX RPKI coverage analysis */ +export const rpkiIXCoverageSchema = z.object({ + ix_name: z + .string() + .describe("IX name to analyze (e.g., 'AMS-IX', 'DE-CIX Frankfurt')"), + show_uncovered: z + .boolean() + .optional() + .default(true) + .describe("List ASNs without RPKI coverage"), +}); + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Validate a prefix-origin pair against RPKI ROAs. + * + * @example + * ``` + * > Is 1.1.1.0/24 from AS13335 RPKI-valid? + * + * Returns: Validation state, matching ROAs, and explanation. + * ``` + */ +export async function handleRPKIValidation( + input: z.infer +): Promise { + const originASN = parseASN(input.origin_asn); + + // TODO: Implementation steps: + // 1. Query RPKI validator (Routinator or RIPE RPKI) + // 2. Check for matching ROAs + // 3. Determine validation state + // 4. Return structured result + + return { + prefix: input.prefix, + originASN, + state: "unknown", // TODO: From RPKI client + matchingROAs: [], + reason: "RPKI validation not yet implemented", + }; +} + +/** + * Generate RPKI compliance report for an ASN. + * + * Analyzes all announced prefixes for RPKI coverage and generates + * a detailed compliance report with recommendations. + * + * @example + * ``` + * > Generate an RPKI compliance report for AS13335 + * + * Returns: Coverage percentage, uncovered prefixes, invalid ROAs, + * and step-by-step improvement recommendations. + * ``` + */ +export async function handleRPKICompliance( + input: z.infer +): Promise { + const asn = parseASN(input.asn); + + // TODO: Implementation steps: + // 1. Get all announced prefixes from RIPE Stat + // 2. Validate each prefix against RPKI + // 3. Calculate coverage metrics + // 4. If include_recommendations, use AI to generate advice + // 5. Return comprehensive report + + return { + asn, + name: "", // TODO: From PeeringDB + totalPrefixes: 0, + validPrefixes: 0, + invalidPrefixes: 0, + unknownPrefixes: 0, + coveragePercent: 0, + recommendations: input.include_recommendations + ? [ + "Create ROAs for all announced prefixes via your RIR portal", + "Set max-length equal to prefix length to prevent sub-prefix hijacks", + "Enable RPKI-invalid route filtering on all BGP sessions", + "Monitor RPKI validation state with PeerCortex or similar tools", + ] + : [], + generatedAt: new Date().toISOString(), + }; +} + +/** + * Analyze RPKI coverage at an Internet Exchange. + * + * Lists all participants at an IX and their RPKI deployment status. + * Useful for peering coordinators and IX operators. + * + * @example + * ``` + * > Which ASNs at AMS-IX don't have RPKI? + * + * Returns: List of ASNs without ROA coverage, grouped by severity. + * ``` + */ +export async function handleRPKIIXCoverage( + input: z.infer +): Promise<{ + ix: string; + totalParticipants: number; + withRPKI: number; + withoutRPKI: number; + coveragePercent: number; + uncoveredASNs?: ReadonlyArray<{ + asn: number; + name: string; + prefixCount: number; + }>; +}> { + // TODO: Implementation steps: + // 1. Search PeeringDB for the IX + // 2. Get all participants at the IX + // 3. For each participant, check RPKI coverage + // 4. Calculate aggregate statistics + // 5. Return report with optional uncovered ASN list + + return { + ix: input.ix_name, + totalParticipants: 0, + withRPKI: 0, + withoutRPKI: 0, + coveragePercent: 0, + uncoveredASNs: input.show_uncovered ? [] : undefined, + }; +} diff --git a/src/mcp-server/tools/security.ts b/src/mcp-server/tools/security.ts new file mode 100644 index 0000000..215cf3e --- /dev/null +++ b/src/mcp-server/tools/security.ts @@ -0,0 +1,322 @@ +/** + * @module mcp-server/tools/security + * MCP Tool: BGP security — hijack detection, route leak analysis (ASPA-based), + * bogon filtering, and blacklist checks. + * + * Provides security-focused analysis of BGP routing using RPKI ROV and ASPA + * validation from bgproutes.io, combined with historical data from RIPE Stat + * and other sources. + */ + +import { z } from "zod"; +import type { ASN, AnomalySeverity } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for hijack detection */ +export const hijackDetectionSchema = z.object({ + prefix: z + .string() + .describe("IP prefix to check for hijacks (e.g., '1.1.1.0/24')"), + expectedOriginAsn: z + .number() + .optional() + .describe("Expected origin ASN (if known)"), + includeHistory: z + .boolean() + .optional() + .default(true) + .describe("Include historical hijack events"), +}); + +/** Input schema for ASPA-based route leak detection */ +export const routeLeakDetectionSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to check for route leaks involving their prefixes"), + prefixes: z + .array(z.string()) + .optional() + .describe("Specific prefixes to check (default: all announced by ASN)"), + timeRange: z + .object({ + start: z.string().describe("Start time (ISO 8601)"), + end: z.string().describe("End time (ISO 8601)"), + }) + .optional() + .describe("Time range to search for leaks"), +}); + +/** Input schema for bogon check */ +export const bogonCheckSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to check for bogon origination or path issues"), + includeReserved: z + .boolean() + .optional() + .default(true) + .describe("Check for reserved/unallocated prefix announcements"), + includeBogonAsns: z + .boolean() + .optional() + .default(true) + .describe("Check for bogon ASNs in AS paths"), +}); + +/** Input schema for blacklist check */ +export const blacklistCheckSchema = z.object({ + resource: z + .string() + .describe("IP address, prefix, or ASN to check against blacklists"), + lists: z + .array(z.string()) + .optional() + .describe("Specific blacklists to check (default: all known)"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** Detected BGP hijack */ +export interface HijackEvent { + readonly prefix: string; + readonly legitimateOrigin: ASN; + readonly hijackerAsn: ASN; + readonly hijackerName: string; + readonly type: "exact-match" | "sub-prefix" | "squat"; + readonly severity: AnomalySeverity; + readonly rpkiStatus: "valid" | "invalid" | "not-found" | "unknown"; + readonly firstSeen: string; + readonly lastSeen: string; + readonly affectedPaths: number; + readonly description: string; +} + +/** Hijack detection result */ +export interface HijackDetectionResult { + readonly prefix: string; + readonly expectedOriginAsn: ASN | null; + readonly currentOrigins: ReadonlyArray<{ + readonly asn: ASN; + readonly name: string; + readonly rpkiValid: boolean; + }>; + readonly activeHijacks: ReadonlyArray; + readonly historicalHijacks: ReadonlyArray; + readonly riskLevel: AnomalySeverity; + readonly recommendations: ReadonlyArray; +} + +/** ASPA-based route leak event */ +export interface RouteLeakEvent { + readonly prefix: string; + readonly leakerAsn: ASN; + readonly leakerName: string; + readonly leakedTo: ReadonlyArray; + readonly aspaValidation: { + readonly state: "invalid"; + readonly violatingHop: { + readonly asn: ASN; + readonly position: number; + readonly reason: string; + }; + }; + readonly severity: AnomalySeverity; + readonly firstSeen: string; + readonly lastSeen: string; + readonly description: string; +} + +/** Route leak detection result */ +export interface RouteLeakDetectionResult { + readonly asn: ASN; + readonly name: string; + readonly prefixesChecked: number; + readonly activeLeaks: ReadonlyArray; + readonly historicalLeaks: ReadonlyArray; + readonly aspaStatus: { + readonly hasAspaObjects: boolean; + readonly aspaObjectCount: number; + readonly coveragePercent: number; + }; + readonly riskLevel: AnomalySeverity; + readonly recommendations: ReadonlyArray; +} + +/** Bogon check finding */ +export interface BogonFinding { + readonly type: "bogon_prefix" | "bogon_asn" | "reserved_prefix" | "unallocated_prefix"; + readonly resource: string; + readonly severity: AnomalySeverity; + readonly description: string; + readonly asPath: ReadonlyArray; +} + +/** Bogon check result */ +export interface BogonCheckResult { + readonly asn: ASN; + readonly findings: ReadonlyArray; + readonly bogonPrefixCount: number; + readonly bogonAsnInPathCount: number; + readonly clean: boolean; + readonly recommendations: ReadonlyArray; +} + +/** Blacklist check result */ +export interface BlacklistCheckResult { + readonly resource: string; + readonly listed: boolean; + readonly listings: ReadonlyArray<{ + readonly list: string; + readonly listedSince: string; + readonly reason: string; + readonly delistUrl: string; + }>; + readonly clean: boolean; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Detect active and historical BGP hijacks for a prefix. + * + * Checks for unauthorized origin ASes, sub-prefix hijacks, and route + * squatting using RPKI ROV validation and MOAS conflict detection. + * + * @param input - Validated detection parameters + * @returns Hijack detection result with active/historical events + * + * @example + * ``` + * > Check if 1.1.1.0/24 is being hijacked + * + * Returns: Current origins, RPKI validation, any MOAS conflicts, + * and historical hijack events. + * ``` + */ +export async function handleHijackDetection( + input: z.infer +): Promise { + // TODO: Query bgproutes.io RIB for current origins of this prefix + // TODO: Validate each origin via RPKI ROV + // TODO: Compare with expected origin if provided + // TODO: Check for sub-prefix announcements (more-specific hijacks) + // TODO: Query historical data from RIPE Stat BGP updates + + return { + prefix: input.prefix, + expectedOriginAsn: input.expectedOriginAsn ?? null, + currentOrigins: [], + activeHijacks: [], + historicalHijacks: [], + riskLevel: "info", + recommendations: [ + "Create ROAs for all your prefixes to enable RPKI-based hijack protection", + "Monitor BGP announcements in real-time for early hijack detection", + "Register ASPA objects to protect against route leaks", + ], + }; +} + +/** + * Detect route leaks using ASPA validation. + * + * Analyzes BGP paths for ASPA validation failures, which indicate + * potential route leaks where a customer or peer re-announces routes + * to unauthorized neighbors. + * + * ASPA (Autonomous System Provider Authorization) objects declare which + * ASes are authorized providers. A path that violates these declarations + * indicates a route leak. + * + * @param input - Validated detection parameters + * @returns Route leak detection result with ASPA analysis + * + * @example + * ``` + * > Detect route leaks using ASPA validation for prefixes of AS13335 + * + * Returns: Prefixes with ASPA-invalid paths, the leaking AS, severity, + * and recommendations for deploying ASPA objects. + * ``` + */ +export async function handleRouteLeakDetection( + input: z.infer +): Promise { + // TODO: Get all announced prefixes for the ASN (or use provided list) + // TODO: For each prefix, query bgproutes.io RIB entries with ASPA validation + // TODO: Filter for entries where aspaValidation.state === "invalid" + // TODO: Identify the leaking AS (the hop that violates ASPA) + // TODO: Query historical updates for past leak events + // TODO: Check if the ASN has registered ASPA objects + + const _input = input; + + return { + asn: 0, // TODO: parseASN(input.asn) + name: "", + prefixesChecked: 0, + activeLeaks: [], + historicalLeaks: [], + aspaStatus: { + hasAspaObjects: false, + aspaObjectCount: 0, + coveragePercent: 0, + }, + riskLevel: "info", + recommendations: [ + "Register ASPA objects to declare your authorized upstream providers", + "Monitor ASPA validation results for your prefixes in real-time", + "Coordinate with your upstreams to ensure they also deploy ASPA", + "Use bgproutes.io real-time stream for immediate leak alerting", + ], + }; +} + +/** + * Check for bogon prefixes and bogon ASNs in routing. + * + * @param input - Validated check parameters + * @returns Bogon check result with findings + */ +export async function handleBogonCheck( + input: z.infer +): Promise { + // TODO: Get all announced prefixes for the ASN + // TODO: Check against IANA reserved/special-purpose ranges + // TODO: Check AS paths for bogon ASNs (reserved, documentation, private) + // TODO: Check for unallocated prefixes from RIR data + + const _input = input; + + return { + asn: 0, + findings: [], + bogonPrefixCount: 0, + bogonAsnInPathCount: 0, + clean: true, + recommendations: [], + }; +} + +/** + * Check a resource against known blacklists and reputation databases. + * + * @param input - Validated check parameters + * @returns Blacklist check result with listings + */ +export async function handleBlacklistCheck( + input: z.infer +): Promise { + // TODO: Check against Spamhaus (DROP, EDROP, ASN-DROP) + // TODO: Check against Team Cymru bogon lists + // TODO: Check against various DNSBL services + // TODO: Aggregate results from specified or all lists + + return { + resource: input.resource, + listed: false, + listings: [], + clean: true, + }; +} diff --git a/src/mcp-server/tools/topology.ts b/src/mcp-server/tools/topology.ts new file mode 100644 index 0000000..e093d57 --- /dev/null +++ b/src/mcp-server/tools/topology.ts @@ -0,0 +1,233 @@ +/** + * @module mcp-server/tools/topology + * MCP Tool: AS-level topology — graph analysis, submarine cables, facilities. + * + * Provides AS topology visualization data, submarine cable mapping, and + * facility/colocation analysis using CAIDA, PeeringDB, and bgproutes.io data. + */ + +import { z } from "zod"; +import type { ASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for AS graph query */ +export const asGraphSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("Center ASN for the graph (e.g., 13335)"), + depth: z + .number() + .optional() + .default(2) + .describe("Graph depth (hops from center ASN, default: 2)"), + includeCustomers: z + .boolean() + .optional() + .default(true) + .describe("Include customer ASes in the graph"), + includeProviders: z + .boolean() + .optional() + .default(true) + .describe("Include provider ASes in the graph"), + includePeers: z + .boolean() + .optional() + .default(true) + .describe("Include peering ASes in the graph"), +}); + +/** Input schema for submarine cable lookup */ +export const submarineCableSchema = z.object({ + region: z + .string() + .optional() + .describe("Geographic region to filter (e.g., 'transatlantic', 'transpacific', 'europe')"), + landingPoint: z + .string() + .optional() + .describe("Landing point city or country to search for"), + asn: z + .union([z.string(), z.number()]) + .optional() + .describe("ASN to find connected submarine cables for"), +}); + +/** Input schema for facility analysis */ +export const facilityAnalysisSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to analyze facility presence for"), + targetAsn: z + .union([z.string(), z.number()]) + .optional() + .describe("Optional target ASN to find common facilities"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** Node in the AS topology graph */ +export interface ASGraphNode { + readonly asn: ASN; + readonly name: string; + readonly rank: number | null; + readonly type: "transit" | "content" | "enterprise" | "access" | "ixp"; + readonly country: string; + readonly customerConeSize: number; +} + +/** Edge in the AS topology graph */ +export interface ASGraphEdge { + readonly from: ASN; + readonly to: ASN; + readonly relationship: "provider-customer" | "peer-to-peer" | "sibling"; + readonly pathCount: number; + readonly active: boolean; +} + +/** AS topology graph */ +export interface ASGraph { + readonly centerAsn: ASN; + readonly centerName: string; + readonly nodes: ReadonlyArray; + readonly edges: ReadonlyArray; + readonly depth: number; + readonly totalNodes: number; + readonly totalEdges: number; +} + +/** Submarine cable information */ +export interface SubmarineCable { + readonly name: string; + readonly readyForService: string; + readonly lengthKm: number; + readonly capacityTbps: number; + readonly owners: ReadonlyArray; + readonly landingPoints: ReadonlyArray<{ + readonly city: string; + readonly country: string; + readonly latitude: number; + readonly longitude: number; + }>; +} + +/** Facility presence analysis */ +export interface FacilityAnalysis { + readonly asn: ASN; + readonly name: string; + readonly facilities: ReadonlyArray<{ + readonly id: number; + readonly name: string; + readonly city: string; + readonly country: string; + readonly networks: number; + readonly ixesPresent: ReadonlyArray; + }>; + readonly commonFacilities?: ReadonlyArray<{ + readonly facility: string; + readonly city: string; + readonly bothPresent: boolean; + }>; + readonly recommendations: ReadonlyArray; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Generate an AS-level topology graph centered on an ASN. + * + * Builds a graph of provider, customer, and peer relationships up to + * the specified depth using CAIDA AS-Relationships and bgproutes.io topology. + * + * @param input - Validated graph parameters + * @returns AS topology graph with nodes and edges + * + * @example + * ``` + * > Show me the AS graph around Cloudflare (AS13335) with 2 hops depth + * + * Returns: Graph with Cloudflare at center, transit providers (e.g., AS714 Apple), + * peers, and customers, annotated with relationship types. + * ``` + */ +export async function handleASGraph( + input: z.infer +): Promise { + // TODO: Fetch relationships from CAIDA client + // TODO: Fetch topology from bgproutes.io client + // TODO: Build graph with BFS up to specified depth + // TODO: Annotate nodes with AS Rank and type + // TODO: Deduplicate edges from multiple sources + + return { + centerAsn: 0, // TODO: parseASN(input.asn) + centerName: "", + nodes: [], + edges: [], + depth: input.depth ?? 2, + totalNodes: 0, + totalEdges: 0, + }; +} + +/** + * Look up submarine cable information. + * + * Returns data about submarine cables, their landing points, capacity, + * and ownership. Can be filtered by region, landing point, or ASN. + * + * @param input - Validated cable query parameters + * @returns Array of matching submarine cables + * + * @example + * ``` + * > What submarine cables connect Europe to North America? + * + * Returns: List of transatlantic cables with capacity, owners, and landing points. + * ``` + */ +export async function handleSubmarineCables( + input: z.infer +): Promise> { + // TODO: Query submarine cable dataset (e.g., TeleGeography) + // TODO: Filter by region, landing point, or ASN facilities + // TODO: Cross-reference with facility data from PeeringDB + + const _input = input; + return []; // TODO: Implement +} + +/** + * Analyze facility presence and colocation opportunities for an ASN. + * + * Lists all facilities where the ASN has presence, and optionally finds + * common facilities with a target ASN for potential peering. + * + * @param input - Validated analysis parameters + * @returns Facility presence analysis with recommendations + * + * @example + * ``` + * > Where can AS32934 (Meta) and AS13335 (Cloudflare) interconnect? + * + * Returns: Facilities where both are present, IXPs at those facilities, + * and recommendations for establishing interconnection. + * ``` + */ +export async function handleFacilityAnalysis( + input: z.infer +): Promise { + // TODO: Query PeeringDB for facility presence (netfac) + // TODO: If targetAsn provided, find overlap + // TODO: List IXPs present at each facility + // TODO: Generate interconnection recommendations + + return { + asn: 0, // TODO: parseASN(input.asn) + name: "", + facilities: [], + commonFacilities: input.targetAsn ? [] : undefined, + recommendations: [], + }; +} diff --git a/src/mcp-server/tools/traffic.ts b/src/mcp-server/tools/traffic.ts new file mode 100644 index 0000000..4cf7208 --- /dev/null +++ b/src/mcp-server/tools/traffic.ts @@ -0,0 +1,225 @@ +/** + * @module mcp-server/tools/traffic + * MCP Tool: IX traffic statistics and port utilization. + * + * Provides IX-level traffic data, trend analysis, and port utilization + * monitoring using public IX statistics APIs. + */ + +import { z } from "zod"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for IX traffic query */ +export const ixTrafficSchema = z.object({ + ix: z + .string() + .describe("IX name or identifier (e.g., 'de-cix-frankfurt', 'ams-ix', 'linx-lon1')"), + period: z + .string() + .optional() + .default("30d") + .describe("Time period (e.g., '7d', '30d', '12m', '1y')"), + granularity: z + .enum(["5min", "hourly", "daily", "weekly", "monthly"]) + .optional() + .default("daily") + .describe("Data point granularity (default: daily)"), +}); + +/** Input schema for IX comparison */ +export const ixComparisonSchema = z.object({ + ixes: z + .array(z.string()) + .min(2) + .max(10) + .describe("Array of IX identifiers to compare (min 2, max 10)"), + period: z + .string() + .optional() + .default("30d") + .describe("Time period for comparison"), +}); + +/** Input schema for port utilization */ +export const portUtilizationSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to check port utilization for"), + ix: z + .string() + .optional() + .describe("Specific IX to check (default: all connected IXes)"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** Traffic trend data point */ +export interface TrafficTrend { + readonly timestamp: string; + readonly peakBps: number; + readonly avgBps: number; + readonly p95Bps: number; +} + +/** IX traffic report */ +export interface IXTrafficReport { + readonly ix: string; + readonly displayName: string; + readonly period: string; + readonly currentStats: { + readonly peakBps: number; + readonly avgBps: number; + readonly connectedNetworks: number; + readonly totalPorts: number; + readonly totalCapacityBps: number; + }; + readonly trends: ReadonlyArray; + readonly growth: { + readonly peakGrowthPercent: number; + readonly avgGrowthPercent: number; + readonly networkGrowthPercent: number; + }; + readonly fetchedAt: string; +} + +/** IX comparison result */ +export interface IXComparisonReport { + readonly ixes: ReadonlyArray<{ + readonly ix: string; + readonly displayName: string; + readonly peakTbps: number; + readonly avgTbps: number; + readonly networks: number; + readonly growthPercent: number; + }>; + readonly period: string; + readonly largestByPeak: string; + readonly fastestGrowing: string; +} + +/** Port utilization report */ +export interface PortUtilizationReport { + readonly asn: number; + readonly name: string; + readonly ixPorts: ReadonlyArray<{ + readonly ix: string; + readonly portSpeedGbps: number; + readonly avgUtilizationPercent: number; + readonly peakUtilizationPercent: number; + readonly recommendation: string; + }>; + readonly overallRecommendation: string; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Get traffic statistics and trends for an Internet Exchange. + * + * @param input - Validated traffic query parameters + * @returns Traffic report with historical trends + * + * @example + * ``` + * > Show IX traffic trends at DE-CIX Frankfurt for the last 12 months + * + * Returns: Current peak/avg traffic, monthly trends, growth rates, + * and network count over time. + * ``` + */ +export async function handleIXTraffic( + input: z.infer +): Promise { + // TODO: Query IX traffic client for stats + // TODO: Parse period string and calculate date range + // TODO: Aggregate data points to requested granularity + // TODO: Calculate growth metrics + + return { + ix: input.ix, + displayName: input.ix.replace(/-/g, " ").toUpperCase(), + period: input.period ?? "30d", + currentStats: { + peakBps: 0, + avgBps: 0, + connectedNetworks: 0, + totalPorts: 0, + totalCapacityBps: 0, + }, + trends: [], + growth: { + peakGrowthPercent: 0, + avgGrowthPercent: 0, + networkGrowthPercent: 0, + }, + fetchedAt: new Date().toISOString(), + }; +} + +/** + * Compare traffic statistics across multiple IXes. + * + * @param input - Validated comparison parameters + * @returns Side-by-side IX comparison + * + * @example + * ``` + * > Compare DE-CIX Frankfurt, AMS-IX, and LINX traffic + * + * Returns: Peak/avg traffic, network count, and growth for each IX. + * ``` + */ +export async function handleIXComparison( + input: z.infer +): Promise { + // TODO: Fetch traffic for each IX in parallel + // TODO: Normalize units and time periods + // TODO: Rank by peak, average, and growth + + return { + ixes: input.ixes.map((ix) => ({ + ix, + displayName: ix.replace(/-/g, " ").toUpperCase(), + peakTbps: 0, + avgTbps: 0, + networks: 0, + growthPercent: 0, + })), + period: input.period ?? "30d", + largestByPeak: input.ixes[0], + fastestGrowing: input.ixes[0], + }; +} + +/** + * Analyze port utilization for an ASN across its IX connections. + * + * @param input - Validated utilization parameters + * @returns Port utilization report with upgrade recommendations + * + * @example + * ``` + * > Is AS13335 (Cloudflare) oversubscribed at any IX? + * + * Returns: Port speeds, utilization percentages, and upgrade recommendations + * for each IX connection. + * ``` + */ +export async function handlePortUtilization( + input: z.infer +): Promise { + // TODO: Query PeeringDB for IX port speeds + // TODO: Cross-reference with IX traffic data if available + // TODO: Generate utilization estimates and recommendations + + const _input = input; + + return { + asn: 0, // TODO: parseASN(input.asn) + name: "", + ixPorts: [], + overallRecommendation: + "Insufficient data — port utilization requires IX-specific telemetry access.", + }; +} diff --git a/src/mcp-server/tools/transit.ts b/src/mcp-server/tools/transit.ts new file mode 100644 index 0000000..e600048 --- /dev/null +++ b/src/mcp-server/tools/transit.ts @@ -0,0 +1,251 @@ +/** + * @module mcp-server/tools/transit + * MCP Tool: Transit and upstream analysis — diversity, cost, optimization. + * + * Analyzes an ASN's upstream providers, evaluates transit diversity, + * and provides recommendations for peering vs. transit decisions. + */ + +import { z } from "zod"; +import type { ASN } from "../../types/common.js"; + +// ── Tool Schemas ───────────────────────────────────────── + +/** Input schema for upstream analysis */ +export const upstreamAnalysisSchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to analyze upstreams for (e.g., 13335 or 'AS13335')"), + includeHistory: z + .boolean() + .optional() + .default(false) + .describe("Include historical upstream changes"), +}); + +/** Input schema for transit diversity assessment */ +export const transitDiversitySchema = z.object({ + asn: z + .union([z.string(), z.number()]) + .describe("ASN to assess transit diversity for"), + minimumUpstreams: z + .number() + .optional() + .default(2) + .describe("Minimum recommended number of upstreams"), +}); + +/** Input schema for peering vs. transit comparison */ +export const peeringVsTransitSchema = z.object({ + sourceAsn: z + .union([z.string(), z.number()]) + .describe("Your ASN"), + targetAsn: z + .union([z.string(), z.number()]) + .describe("Target ASN to evaluate peering with"), + estimatedTrafficGbps: z + .number() + .optional() + .describe("Estimated bilateral traffic in Gbps"), + transitCostPerMbps: z + .number() + .optional() + .describe("Current transit cost per Mbps (USD)"), +}); + +// ── Result Types ───────────────────────────────────────── + +/** Upstream provider information */ +export interface UpstreamProvider { + readonly asn: ASN; + readonly name: string; + readonly relationship: "transit" | "peer" | "partial-transit"; + readonly prefixesVia: number; + readonly percentOfPaths: number; + readonly firstSeen: string; + readonly lastSeen: string; + readonly stability: "stable" | "intermittent" | "new"; +} + +/** Upstream analysis result */ +export interface UpstreamAnalysis { + readonly asn: ASN; + readonly name: string; + readonly upstreams: ReadonlyArray; + readonly upstreamCount: number; + readonly transitProviders: ReadonlyArray; + readonly singleHomedPrefixes: ReadonlyArray; + readonly recommendations: ReadonlyArray; +} + +/** Transit diversity assessment */ +export interface TransitDiversityReport { + readonly asn: ASN; + readonly name: string; + readonly upstreamCount: number; + readonly diversityScore: number; + readonly singlePointsOfFailure: ReadonlyArray<{ + readonly description: string; + readonly severity: "critical" | "high" | "medium" | "low"; + readonly affectedPrefixes: ReadonlyArray; + }>; + readonly geographicDiversity: { + readonly countries: ReadonlyArray; + readonly continents: ReadonlyArray; + readonly score: number; + }; + readonly recommendations: ReadonlyArray; +} + +/** Peering vs. transit cost comparison */ +export interface PeeringVsTransitComparison { + readonly sourceAsn: ASN; + readonly targetAsn: ASN; + readonly targetName: string; + readonly currentPath: { + readonly asPath: ReadonlyArray; + readonly hopCount: number; + readonly transitProviders: ReadonlyArray; + }; + readonly peeringPath: { + readonly commonIXs: ReadonlyArray; + readonly commonFacilities: ReadonlyArray; + readonly estimatedRttReductionMs: number; + }; + readonly costAnalysis: { + readonly currentMonthlyCostUsd: number | null; + readonly estimatedPeeringSetupCostUsd: number; + readonly estimatedMonthlySavingsUsd: number | null; + readonly breakEvenMonths: number | null; + }; + readonly recommendation: string; +} + +// ── Tool Handlers ──────────────────────────────────────── + +/** + * Analyze upstream providers for an ASN. + * + * Identifies all transit providers, evaluates their role in routing, + * and highlights single-homed prefixes. + * + * @param input - Validated analysis parameters + * @returns Upstream analysis with provider details + * + * @example + * ``` + * > Show me the upstream providers for AS32934 (Meta) + * + * Returns: List of transit providers, percentage of paths via each, + * stability assessment, and single-homed prefix warnings. + * ``` + */ +export async function handleUpstreamAnalysis( + input: z.infer +): Promise { + // TODO: Query bgp.he.net and RIPE Stat for upstream data + // TODO: Query CAIDA for AS relationship classification + // TODO: Cross-reference with BGP state from Route Views + // TODO: Calculate path percentages and stability metrics + + const _input = input; + + return { + asn: 0, // TODO: parseASN(input.asn) + name: "", + upstreams: [], + upstreamCount: 0, + transitProviders: [], + singleHomedPrefixes: [], + recommendations: [ + "Consider adding a second transit provider for redundancy", + "Evaluate peering at shared IXPs to reduce transit dependency", + ], + }; +} + +/** + * Assess transit diversity and resilience for an ASN. + * + * Evaluates single points of failure, geographic diversity, and + * provides a diversity score. + * + * @param input - Validated assessment parameters + * @returns Diversity report with risk assessment + */ +export async function handleTransitDiversity( + input: z.infer +): Promise { + // TODO: Analyze upstream paths for redundancy + // TODO: Check geographic distribution of upstreams + // TODO: Identify single points of failure + // TODO: Score diversity (0-100) + + const _input = input; + + return { + asn: 0, + name: "", + upstreamCount: 0, + diversityScore: 0, + singlePointsOfFailure: [], + geographicDiversity: { + countries: [], + continents: [], + score: 0, + }, + recommendations: [], + }; +} + +/** + * Compare peering directly vs. using transit to reach a target ASN. + * + * Evaluates whether establishing a direct peering session would be + * beneficial in terms of latency, cost, and reliability. + * + * @param input - Validated comparison parameters + * @returns Cost/benefit analysis with recommendation + * + * @example + * ``` + * > What would change if AS32934 peered directly with AS13335 + * > instead of using transit? + * + * Returns: Current path analysis, common IX/facility overlap, + * estimated latency improvement, and cost comparison. + * ``` + */ +export async function handlePeeringVsTransit( + input: z.infer +): Promise { + // TODO: Get current BGP path between source and target + // TODO: Find common IXPs and facilities via PeeringDB + // TODO: Estimate latency reduction from direct peering + // TODO: Calculate cost comparison if traffic estimate provided + + return { + sourceAsn: 0, // TODO: parseASN(input.sourceAsn) + targetAsn: 0, // TODO: parseASN(input.targetAsn) + targetName: "", + currentPath: { + asPath: [], + hopCount: 0, + transitProviders: [], + }, + peeringPath: { + commonIXs: [], + commonFacilities: [], + estimatedRttReductionMs: 0, + }, + costAnalysis: { + currentMonthlyCostUsd: input.transitCostPerMbps != null && input.estimatedTrafficGbps != null + ? input.transitCostPerMbps * input.estimatedTrafficGbps * 1000 + : null, + estimatedPeeringSetupCostUsd: 0, + estimatedMonthlySavingsUsd: null, + breakEvenMonths: null, + }, + recommendation: "Insufficient data — provide traffic and cost estimates for a full comparison.", + }; +} diff --git a/src/sources/bgp-he.ts b/src/sources/bgp-he.ts new file mode 100644 index 0000000..41ddc7a --- /dev/null +++ b/src/sources/bgp-he.ts @@ -0,0 +1,216 @@ +/** + * @module sources/bgp-he + * bgp.he.net scraper for ASN information, peering data, and prefix lists. + * + * Hurricane Electric's BGP Toolkit provides a comprehensive view of the + * global routing table. This module scrapes the public web interface + * since no official API is available. + * + * @see https://bgp.he.net/ + */ + +import * as cheerio from "cheerio"; +import type { HENetASNInfo } from "../types/bgp.js"; +import type { ASN } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const HE_BGP_BASE_URL = "https://bgp.he.net"; + +interface BGPHEClientConfig { + readonly baseUrl?: string; + readonly timeoutMs?: number; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * bgp.he.net scraper client. + * + * Scrapes ASN information, peer lists, prefix originations, and IX + * participation from Hurricane Electric's BGP Toolkit. + * + * @example + * ```typescript + * const client = createBGPHEClient(); + * const info = await client.getASNInfo(13335); + * console.log(info.name); // "CLOUDFLARENET" + * ``` + */ +export interface BGPHEClient { + /** Get comprehensive ASN info including peers, prefixes, and IX participation */ + getASNInfo(asn: ASN): Promise; + + /** Get list of peers for an ASN */ + getPeers(asn: ASN): Promise; + + /** Get originated prefixes for an ASN */ + getPrefixes(asn: ASN): Promise; + + /** Get upstream providers for an ASN */ + getUpstreams(asn: ASN): Promise; + + /** Get downstream customers for an ASN */ + getDownstreams(asn: ASN): Promise; + + /** Check if bgp.he.net is reachable */ + healthCheck(): Promise; +} + +/** + * Create a new bgp.he.net scraper client. + * + * @param config - Client configuration + * @returns A configured bgp.he.net client instance + */ +export function createBGPHEClient( + config: BGPHEClientConfig = {} +): BGPHEClient { + const baseUrl = config.baseUrl ?? HE_BGP_BASE_URL; + const timeoutMs = config.timeoutMs ?? 20000; + + /** + * Fetch and parse an HTML page from bgp.he.net. + */ + async function fetchPage(path: string): Promise { + const url = `${baseUrl}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; PeerCortex/0.1.0; +https://github.com/peercortex/peercortex)", + Accept: "text/html", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `bgp.he.net returned ${response.status}`, + "SOURCE_UNAVAILABLE", + "bgp_he" + ); + } + + const html = await response.text(); + return cheerio.load(html); + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `bgp.he.net scraping failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "bgp_he", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + /** + * Parse peer table rows from bgp.he.net HTML. + */ + function parsePeerTable( + $: cheerio.CheerioAPI, + tableId: string + ): ReadonlyArray<{ asn: number; name: string; v4: boolean; v6: boolean }> { + const peers: Array<{ asn: number; name: string; v4: boolean; v6: boolean }> = []; + + $(`#${tableId} tbody tr`).each((_i, row) => { + const cells = $(row).find("td"); + if (cells.length >= 4) { + const asnText = $(cells[0]).text().trim().replace("AS", ""); + const asn = parseInt(asnText, 10); + const name = $(cells[1]).text().trim(); + const v4 = $(cells[2]).text().trim() !== ""; + const v6 = $(cells[3]).text().trim() !== ""; + + if (!isNaN(asn)) { + peers.push({ asn, name, v4, v6 }); + } + } + }); + + return peers; + } + + return { + async getASNInfo(asn: ASN): Promise { + // TODO: Implement full scraping of bgp.he.net/AS{asn} + // This requires parsing multiple pages: + // - /AS{asn} — overview page + // - /AS{asn}#_peers — peer table + // - /AS{asn}#_prefixes — originated prefixes + // - /AS{asn}#_graph — AS graph + + const $ = await fetchPage(`/AS${asn}`); + + // Parse ASN name from page title + const title = $("title").text(); + const nameMatch = title.match(/AS\d+\s+(.+?)\s*[-|]/); + const name = nameMatch ? nameMatch[1].trim() : "Unknown"; + + // Parse description from whois block + const description = $("#whois").text().trim().slice(0, 500); + + // TODO: Parse country, contacts, prefixes, peers from the HTML tables + // For now, return a skeleton with the name populated + + return { + asn, + name, + description, + country: "", // TODO: Extract from whois data + emailContacts: [], // TODO: Parse contact info + abuseContacts: [], // TODO: Parse abuse contacts + prefixesOriginated: { + v4: [], // TODO: Scrape from prefixes tab + v6: [], // TODO: Scrape from prefixes tab + }, + peers: [...parsePeerTable($, "peers")], + upstreams: [], // TODO: Scrape upstream table + downstreams: [], // TODO: Scrape downstream table + ixParticipation: [], // TODO: Scrape IX table + }; + }, + + async getPeers(asn: ASN): Promise { + const $ = await fetchPage(`/AS${asn}#_peers`); + return parsePeerTable($, "peers"); + }, + + async getPrefixes(asn: ASN): Promise { + // TODO: Scrape prefixes from /AS{asn}#_prefixes and /AS{asn}#_prefixes6 + const _$ = await fetchPage(`/AS${asn}#_prefixes`); + return { + v4: [], // TODO: Parse v4 prefix table + v6: [], // TODO: Parse v6 prefix table + }; + }, + + async getUpstreams(asn: ASN): Promise { + // TODO: Scrape upstream ASNs from graph page + const _asn = asn; + return []; // TODO: Implement + }, + + async getDownstreams(asn: ASN): Promise { + // TODO: Scrape downstream ASNs from graph page + const _asn = asn; + return []; // TODO: Implement + }, + + async healthCheck(): Promise { + try { + await fetchPage("/AS13335"); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/bgproutes-io.ts b/src/sources/bgproutes-io.ts new file mode 100644 index 0000000..4ea5e9d --- /dev/null +++ b/src/sources/bgproutes-io.ts @@ -0,0 +1,304 @@ +/** + * @module sources/bgproutes-io + * bgproutes.io REST API client for RIB, updates, vantage points, and topology. + * + * bgproutes.io provides a real-time BGP data platform with RPKI ROV and ASPA + * validation on every route entry. This client wraps four REST endpoints plus + * a real-time streaming interface. + * + * @see https://bgproutes.io/docs/api + */ + +import type { + BgpRoutesIoRibEntry, + BgpRoutesIoUpdate, + BgpRoutesIoVantagePoint, + BgpRoutesIoTopologyLink, +} from "../types/bgp.js"; +import type { ASN } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const BGPROUTES_IO_BASE_URL = "https://api.bgproutes.io/v1"; + +interface BgpRoutesIoClientConfig { + /** API key for authenticated access (optional for public endpoints) */ + readonly apiKey?: string; + /** Base URL override for testing */ + readonly baseUrl?: string; + /** Request timeout in milliseconds */ + readonly timeoutMs?: number; +} + +// ── Time Range ─────────────────────────────────────────── + +/** Time range for update queries */ +export interface TimeRange { + /** Start of the window (ISO 8601) */ + readonly start: string; + /** End of the window (ISO 8601) */ + readonly end: string; +} + +// ── Client Interface ───────────────────────────────────── + +/** + * bgproutes.io API client. + * + * Provides typed access to RIB entries, BGP updates, vantage points, and + * AS-level topology data. Every route entry includes RPKI ROV status and + * ASPA validation results. + * + * @example + * ```typescript + * const client = createBgpRoutesIoClient({ apiKey: process.env.BGPROUTES_API_KEY }); + * const rib = await client.getRibEntries("1.1.1.0/24"); + * console.log(rib[0].rpkiStatus); // "valid" + * console.log(rib[0].aspaValidation.state); // "valid" + * ``` + */ +export interface BgpRoutesIoClient { + /** + * Fetch current RIB entries for a prefix. + * + * Returns all routes seen across vantage points, each annotated with + * RPKI ROV status and ASPA validation. + * + * @param prefix - IP prefix (e.g., "1.1.1.0/24" or "2606:4700::/32") + * @returns Array of RIB entries with validation metadata + */ + getRibEntries(prefix: string): Promise>; + + /** + * Fetch BGP updates for a prefix within a time range. + * + * @param prefix - IP prefix to query updates for + * @param timeRange - Start and end time (ISO 8601) + * @returns Array of BGP update messages + */ + getUpdates( + prefix: string, + timeRange: TimeRange + ): Promise>; + + /** + * List all available vantage points (BGP collectors/peers). + * + * @returns Array of vantage point metadata + */ + getVantagePoints(): Promise>; + + /** + * Fetch AS-level topology links for an ASN. + * + * Returns upstream, downstream, and peer relationships observed + * from BGP data, with link classification. + * + * @param asn - Autonomous System Number + * @returns Array of topology links involving the given ASN + */ + getTopology(asn: ASN): Promise>; + + /** + * Subscribe to real-time BGP updates for a prefix. + * + * Returns an async iterable that yields updates as they are observed. + * The caller is responsible for breaking out of the loop to stop streaming. + * + * @param prefix - IP prefix to monitor + * @returns Async iterable of real-time BGP updates + */ + getRealtimeStream( + prefix: string + ): AsyncIterable; + + /** Check if the bgproutes.io API is reachable */ + healthCheck(): Promise; +} + +// ── Client Factory ─────────────────────────────────────── + +/** + * Create a new bgproutes.io API client. + * + * @param config - Client configuration + * @returns A configured bgproutes.io client instance + * + * @example + * ```typescript + * const client = createBgpRoutesIoClient(); + * const vps = await client.getVantagePoints(); + * console.log(`${vps.length} vantage points available`); + * ``` + */ +export function createBgpRoutesIoClient( + config: BgpRoutesIoClientConfig = {} +): BgpRoutesIoClient { + const baseUrl = config.baseUrl ?? BGPROUTES_IO_BASE_URL; + const apiKey = config.apiKey ?? process.env.BGPROUTES_API_KEY; + const timeoutMs = config.timeoutMs ?? 30000; + + /** + * Build common request headers. + */ + function buildHeaders(): Record { + const headers: Record = { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }; + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; + } + return headers; + } + + /** + * Make a typed GET request to the bgproutes.io API. + */ + async function request( + path: string, + params: Record = {} + ): Promise { + const url = new URL(`${baseUrl}${path}`); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + headers: buildHeaders(), + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `bgproutes.io API error: ${response.status} ${response.statusText}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "bgproutes_io" as never + ); + } + + const body = (await response.json()) as { data: T }; + return body.data; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `bgproutes.io request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "bgproutes_io" as never, + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async getRibEntries( + prefix: string + ): Promise> { + // TODO: Validate prefix format before sending request + return request>("/rib", { prefix }); + }, + + async getUpdates( + prefix: string, + timeRange: TimeRange + ): Promise> { + // TODO: Validate time range (start < end, max window) + return request>("/updates", { + prefix, + start: timeRange.start, + end: timeRange.end, + }); + }, + + async getVantagePoints(): Promise< + ReadonlyArray + > { + return request>( + "/vantage_points" + ); + }, + + async getTopology( + asn: ASN + ): Promise> { + return request>( + `/topology/${asn}` + ); + }, + + async *getRealtimeStream( + prefix: string + ): AsyncIterable { + // TODO: Implement SSE / WebSocket streaming connection + // TODO: Handle reconnection with exponential backoff + // TODO: Parse streaming JSON lines into typed updates + + const url = new URL(`${baseUrl}/stream`); + url.searchParams.set("prefix", prefix); + + const controller = new AbortController(); + + try { + const response = await fetch(url.toString(), { + headers: { + ...buildHeaders(), + Accept: "text/event-stream", + }, + signal: controller.signal, + }); + + if (!response.ok || !response.body) { + throw new PeerCortexError( + `bgproutes.io stream failed: ${response.status}`, + "SOURCE_UNAVAILABLE", + "bgproutes_io" as never + ); + } + + // TODO: Replace with proper SSE parser (e.g., eventsource-parser) + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n").filter((l) => l.startsWith("data:")); + + for (const line of lines) { + const json = line.slice(5).trim(); + if (json) { + yield JSON.parse(json) as BgpRoutesIoUpdate; + } + } + } + } finally { + reader.releaseLock(); + } + } finally { + controller.abort(); + } + }, + + async healthCheck(): Promise { + try { + const vps = await this.getVantagePoints(); + return vps.length > 0; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/caida.ts b/src/sources/caida.ts new file mode 100644 index 0000000..70c52e9 --- /dev/null +++ b/src/sources/caida.ts @@ -0,0 +1,335 @@ +/** + * @module sources/caida + * CAIDA AS-Relationships dataset client. + * + * CAIDA publishes inferred AS-level relationships (provider-customer, + * peer-to-peer, sibling) derived from BGP data. This client fetches and + * parses the serial-2 relationship format. + * + * @see https://www.caida.org/catalog/datasets/as-relationships/ + * @see https://api.asrank.caida.org/v2/docs + */ + +import type { ASN } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const CAIDA_ASRANK_BASE_URL = "https://api.asrank.caida.org/v2"; + +interface CAIDAClientConfig { + /** Base URL override */ + readonly baseUrl?: string; + /** Request timeout in milliseconds */ + readonly timeoutMs?: number; +} + +// ── Types ──────────────────────────────────────────────── + +/** Relationship type between two ASes */ +export type ASRelationshipType = + | "provider-customer" // left provides transit to right + | "customer-provider" // left is customer of right + | "peer-to-peer" // settlement-free peering + | "sibling"; // same organization + +/** A single AS-level relationship */ +export interface ASRelationship { + readonly asnLeft: ASN; + readonly asnRight: ASN; + readonly relationship: ASRelationshipType; + readonly source: string; +} + +/** CAIDA AS Rank entry */ +export interface ASRankEntry { + readonly asn: ASN; + readonly asnName: string; + readonly rank: number; + readonly organization: string; + readonly country: string; + readonly cone: { + readonly numberAsns: number; + readonly numberPrefixes: number; + readonly numberAddresses: number; + }; + readonly asnDegree: { + readonly provider: number; + readonly peer: number; + readonly customer: number; + readonly total: number; + }; +} + +/** Cone (customer cone) of an AS */ +export interface ASCone { + readonly asn: ASN; + readonly asns: ReadonlyArray; + readonly prefixes: ReadonlyArray; + readonly totalAddresses: number; +} + +// ── Client Interface ───────────────────────────────────── + +/** + * CAIDA AS-Relationships and AS Rank client. + * + * @example + * ```typescript + * const caida = createCAIDAClient(); + * const rank = await caida.getASRank(13335); + * console.log(`Cloudflare is ranked #${rank.rank}`); + * + * const rels = await caida.getRelationships(13335); + * const providers = rels.filter(r => r.relationship === "customer-provider"); + * ``` + */ +export interface CAIDAClient { + /** Get AS Rank entry for an ASN */ + getASRank(asn: ASN): Promise; + + /** Get top N ASes by rank */ + getTopASes(limit?: number): Promise>; + + /** Get all known relationships for an ASN */ + getRelationships(asn: ASN): Promise>; + + /** Get the customer cone for an ASN */ + getCustomerCone(asn: ASN): Promise; + + /** Check if the CAIDA API is reachable */ + healthCheck(): Promise; +} + +// ── Client Factory ─────────────────────────────────────── + +/** + * Create a new CAIDA AS-Relationships client. + * + * Uses the CAIDA AS Rank GraphQL/REST API for relationship and ranking data. + * + * @param config - Client configuration + * @returns A configured CAIDA client instance + */ +export function createCAIDAClient( + config: CAIDAClientConfig = {} +): CAIDAClient { + const baseUrl = config.baseUrl ?? CAIDA_ASRANK_BASE_URL; + const timeoutMs = config.timeoutMs ?? 30000; + + /** + * Execute a GraphQL query against the CAIDA AS Rank API. + */ + async function graphql(query: string, variables: Record = {}): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${baseUrl}/graphql`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `CAIDA API error: ${response.status} ${response.statusText}`, + "SOURCE_UNAVAILABLE", + "ripe_stat" // TODO: Add caida as DataSourceName + ); + } + + const body = (await response.json()) as { data: T; errors?: ReadonlyArray<{ message: string }> }; + + if (body.errors && body.errors.length > 0) { + throw new PeerCortexError( + `CAIDA GraphQL error: ${body.errors[0].message}`, + "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + return body.data; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `CAIDA request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + /** + * Map CAIDA relationship code to typed enum. + */ + function mapRelationship(code: number): ASRelationshipType { + switch (code) { + case -1: return "provider-customer"; + case 0: return "peer-to-peer"; + case 1: return "customer-provider"; + case 2: return "sibling"; + default: return "peer-to-peer"; + } + } + + return { + async getASRank(asn: ASN): Promise { + const query = ` + query GetASRank($asn: String!) { + asn(asn: $asn) { + asn + asnName + rank + organization { orgName } + country { iso } + cone { numberAsns numberPrefixes numberAddresses } + asnDegree { provider peer customer total } + } + } + `; + + const result = await graphql<{ + asn: { + asn: number; + asnName: string; + rank: number; + organization: { orgName: string }; + country: { iso: string }; + cone: { numberAsns: number; numberPrefixes: number; numberAddresses: number }; + asnDegree: { provider: number; peer: number; customer: number; total: number }; + }; + }>(query, { asn: String(asn) }); + + return { + asn: result.asn.asn, + asnName: result.asn.asnName, + rank: result.asn.rank, + organization: result.asn.organization.orgName, + country: result.asn.country.iso, + cone: result.asn.cone, + asnDegree: result.asn.asnDegree, + }; + }, + + async getTopASes(limit: number = 20): Promise> { + // TODO: Implement via CAIDA AS Rank API with pagination + const query = ` + query GetTopASes($first: Int!) { + asns(first: $first, sort: "rank") { + edges { + node { + asn + asnName + rank + organization { orgName } + country { iso } + cone { numberAsns numberPrefixes numberAddresses } + asnDegree { provider peer customer total } + } + } + } + } + `; + + const result = await graphql<{ + asns: { + edges: ReadonlyArray<{ + node: { + asn: number; + asnName: string; + rank: number; + organization: { orgName: string }; + country: { iso: string }; + cone: { numberAsns: number; numberPrefixes: number; numberAddresses: number }; + asnDegree: { provider: number; peer: number; customer: number; total: number }; + }; + }>; + }; + }>(query, { first: limit }); + + return result.asns.edges.map((edge) => ({ + asn: edge.node.asn, + asnName: edge.node.asnName, + rank: edge.node.rank, + organization: edge.node.organization.orgName, + country: edge.node.country.iso, + cone: edge.node.cone, + asnDegree: edge.node.asnDegree, + })); + }, + + async getRelationships( + asn: ASN + ): Promise> { + // TODO: Implement via AS Rank API asnLinks query + // TODO: Parse provider/customer/peer relationships + const query = ` + query GetRelationships($asn: String!) { + asn(asn: $asn) { + asnLinks(first: 500) { + edges { + node { + asn0 { asn } + asn1 { asn } + relationship + } + } + } + } + } + `; + + const result = await graphql<{ + asn: { + asnLinks: { + edges: ReadonlyArray<{ + node: { + asn0: { asn: number }; + asn1: { asn: number }; + relationship: number; + }; + }>; + }; + }; + }>(query, { asn: String(asn) }); + + return result.asn.asnLinks.edges.map((edge) => ({ + asnLeft: edge.node.asn0.asn, + asnRight: edge.node.asn1.asn, + relationship: mapRelationship(edge.node.relationship), + source: "caida", + })); + }, + + async getCustomerCone(asn: ASN): Promise { + // TODO: Implement via CAIDA AS Rank cone query + // TODO: This may require downloading the cone dataset for full data + const _asn = asn; + return { + asn, + asns: [], + prefixes: [], + totalAddresses: 0, + }; + }, + + async healthCheck(): Promise { + try { + // Use Cloudflare (AS13335) as a known-good test + await this.getASRank(13335); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/dns.ts b/src/sources/dns.ts new file mode 100644 index 0000000..a8eb486 --- /dev/null +++ b/src/sources/dns.ts @@ -0,0 +1,385 @@ +/** + * @module sources/dns + * DNS resolver client for rDNS lookups, delegation checks, and WHOIS queries. + * + * Provides DNS resolution capabilities used by other modules for hostname + * lookups, delegation verification, and domain intelligence gathering. + * + * @see https://dns.google/resolve + * @see https://cloudflare-dns.com/dns-query + */ + +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +interface DNSClientConfig { + /** DNS-over-HTTPS resolver URL */ + readonly resolverUrl?: string; + /** Request timeout in milliseconds */ + readonly timeoutMs?: number; +} + +// ── Types ──────────────────────────────────────────────── + +/** DNS record types */ +export type DNSRecordType = + | "A" + | "AAAA" + | "CNAME" + | "MX" + | "NS" + | "PTR" + | "SOA" + | "TXT" + | "SRV" + | "CAA" + | "DNSKEY" + | "DS" + | "RRSIG"; + +/** A single DNS record */ +export interface DNSRecord { + readonly name: string; + readonly type: DNSRecordType; + readonly ttl: number; + readonly data: string; +} + +/** DNS resolution result */ +export interface DNSResolutionResult { + readonly query: string; + readonly queryType: DNSRecordType; + readonly status: number; + readonly answers: ReadonlyArray; + readonly authority: ReadonlyArray; + readonly additional: ReadonlyArray; + readonly truncated: boolean; + readonly recursionDesired: boolean; + readonly recursionAvailable: boolean; + readonly authenticData: boolean; + readonly checkingDisabled: boolean; +} + +/** Reverse DNS lookup result */ +export interface ReverseDNSResult { + readonly ip: string; + readonly hostname: string | null; + readonly verified: boolean; +} + +/** DNS delegation information */ +export interface DelegationInfo { + readonly domain: string; + readonly nameservers: ReadonlyArray<{ + readonly hostname: string; + readonly ipv4: ReadonlyArray; + readonly ipv6: ReadonlyArray; + }>; + readonly dnssecEnabled: boolean; + readonly dsRecords: ReadonlyArray; + readonly registrar: string | null; +} + +/** WHOIS summary for a resource */ +export interface WHOISSummary { + readonly resource: string; + readonly type: "ip" | "asn" | "domain"; + readonly registrant: string; + readonly organization: string; + readonly country: string; + readonly registrar: string; + readonly creationDate: string; + readonly expirationDate: string; + readonly abuseContact: string; + readonly rawText: string; +} + +// ── Client Interface ───────────────────────────────────── + +/** + * DNS resolver client for network intelligence queries. + * + * Uses DNS-over-HTTPS (DoH) for reliable, privacy-preserving DNS lookups. + * + * @example + * ```typescript + * const dns = createDNSClient(); + * + * // Reverse DNS lookup + * const rdns = await dns.reverseLookup("1.1.1.1"); + * console.log(rdns.hostname); // "one.one.one.one" + * + * // Delegation check + * const deleg = await dns.getDelegation("cloudflare.com"); + * console.log(deleg.dnssecEnabled); // true + * ``` + */ +export interface DNSClient { + /** Resolve a DNS query */ + resolve(name: string, type: DNSRecordType): Promise; + + /** Perform a reverse DNS lookup for an IP address */ + reverseLookup(ip: string): Promise; + + /** Get delegation information for a domain */ + getDelegation(domain: string): Promise; + + /** Perform a WHOIS lookup for an IP, ASN, or domain */ + whoisLookup(resource: string): Promise; + + /** Batch reverse DNS for multiple IPs */ + batchReverseLookup( + ips: ReadonlyArray + ): Promise>; + + /** Check if the DNS resolver is reachable */ + healthCheck(): Promise; +} + +// ── Client Factory ─────────────────────────────────────── + +/** + * Create a new DNS client using DNS-over-HTTPS. + * + * @param config - Client configuration + * @returns A configured DNS client instance + */ +export function createDNSClient(config: DNSClientConfig = {}): DNSClient { + const resolverUrl = + config.resolverUrl ?? "https://cloudflare-dns.com/dns-query"; + const timeoutMs = config.timeoutMs ?? 10000; + + /** + * Make a DoH GET request. + */ + async function dohQuery( + name: string, + type: string + ): Promise<{ + Status: number; + TC: boolean; + RD: boolean; + RA: boolean; + AD: boolean; + CD: boolean; + Question: ReadonlyArray<{ name: string; type: number }>; + Answer?: ReadonlyArray<{ name: string; type: number; TTL: number; data: string }>; + Authority?: ReadonlyArray<{ name: string; type: number; TTL: number; data: string }>; + Additional?: ReadonlyArray<{ name: string; type: number; TTL: number; data: string }>; + }> { + const url = new URL(resolverUrl); + url.searchParams.set("name", name); + url.searchParams.set("type", type); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/dns-json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `DNS query failed: ${response.status}`, + "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + return (await response.json()) as Awaited>; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `DNS query failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + /** + * Map DoH record type number to string. + */ + function mapRecordType(typeNum: number): DNSRecordType { + const typeMap: Record = { + 1: "A", + 28: "AAAA", + 5: "CNAME", + 15: "MX", + 2: "NS", + 12: "PTR", + 6: "SOA", + 16: "TXT", + 33: "SRV", + 257: "CAA", + 48: "DNSKEY", + 43: "DS", + 46: "RRSIG", + }; + return typeMap[typeNum] ?? ("A" as DNSRecordType); + } + + /** + * Convert IP address to PTR record name. + */ + function ipToPtrName(ip: string): string { + if (ip.includes(":")) { + // IPv6: expand and reverse nibbles + // TODO: Implement full IPv6 expansion + return ip.split(":").reverse().join(".") + ".ip6.arpa"; + } + // IPv4: reverse octets + return ip.split(".").reverse().join(".") + ".in-addr.arpa"; + } + + /** + * Map DoH response records to typed DNSRecord array. + */ + function mapRecords( + records?: ReadonlyArray<{ name: string; type: number; TTL: number; data: string }> + ): ReadonlyArray { + if (!records) return []; + return records.map((r) => ({ + name: r.name, + type: mapRecordType(r.type), + ttl: r.TTL, + data: r.data, + })); + } + + return { + async resolve( + name: string, + type: DNSRecordType + ): Promise { + const result = await dohQuery(name, type); + + return { + query: name, + queryType: type, + status: result.Status, + answers: mapRecords(result.Answer), + authority: mapRecords(result.Authority), + additional: mapRecords(result.Additional), + truncated: result.TC, + recursionDesired: result.RD, + recursionAvailable: result.RA, + authenticData: result.AD, + checkingDisabled: result.CD, + }; + }, + + async reverseLookup(ip: string): Promise { + const ptrName = ipToPtrName(ip); + const result = await dohQuery(ptrName, "PTR"); + + const hostname = + result.Answer && result.Answer.length > 0 + ? result.Answer[0].data.replace(/\.$/, "") + : null; + + // Verify forward-confirmed reverse DNS + let verified = false; + if (hostname) { + try { + const fwdType = ip.includes(":") ? "AAAA" : "A"; + const fwd = await dohQuery(hostname, fwdType); + verified = + fwd.Answer?.some((a) => a.data === ip) ?? false; + } catch { + verified = false; + } + } + + return { ip, hostname, verified }; + }, + + async getDelegation(domain: string): Promise { + // Query NS records + const nsResult = await dohQuery(domain, "NS"); + const nsRecords = nsResult.Answer ?? []; + + // Query DS records for DNSSEC status + const dsResult = await dohQuery(domain, "DS"); + const dsRecords = dsResult.Answer ?? []; + + // Resolve NS hostnames to IPs + // TODO: Parallelize these lookups + const nameservers = await Promise.all( + nsRecords.map(async (ns) => { + const hostname = ns.data.replace(/\.$/, ""); + const [v4Result, v6Result] = await Promise.all([ + dohQuery(hostname, "A").catch(() => null), + dohQuery(hostname, "AAAA").catch(() => null), + ]); + + return { + hostname, + ipv4: (v4Result?.Answer ?? []).map((a) => a.data), + ipv6: (v6Result?.Answer ?? []).map((a) => a.data), + }; + }) + ); + + return { + domain, + nameservers, + dnssecEnabled: dsRecords.length > 0, + dsRecords: mapRecords(dsRecords), + registrar: null, // TODO: Parse from WHOIS + }; + }, + + async whoisLookup(resource: string): Promise { + // TODO: Implement via node-whois package or RIPE Stat WHOIS data call + // TODO: Parse raw WHOIS text into structured fields + // TODO: Detect resource type (IP, ASN, domain) automatically + + return { + resource, + type: resource.match(/^\d+$/) ? "asn" : resource.includes("/") ? "ip" : "domain", + registrant: "", // TODO: Parse from WHOIS response + organization: "", + country: "", + registrar: "", + creationDate: "", + expirationDate: "", + abuseContact: "", + rawText: "", + }; + }, + + async batchReverseLookup( + ips: ReadonlyArray + ): Promise> { + // TODO: Implement rate limiting for large batches + const results = await Promise.allSettled( + ips.map((ip) => this.reverseLookup(ip)) + ); + + return results.map((r, i) => + r.status === "fulfilled" + ? r.value + : { ip: ips[i], hostname: null, verified: false } + ); + }, + + async healthCheck(): Promise { + try { + const result = await this.resolve("cloudflare.com", "A"); + return result.answers.length > 0; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/irr.ts b/src/sources/irr.ts new file mode 100644 index 0000000..5cdd73c --- /dev/null +++ b/src/sources/irr.ts @@ -0,0 +1,254 @@ +/** + * @module sources/irr + * IRR (Internet Routing Registry) and WHOIS query client. + * + * Queries RIPE DB, RADB, ARIN, APNIC, and other IRR databases for + * route objects, as-set expansions, and WHOIS data. + * + * @see https://www.irr.net/ + * @see https://www.ripe.net/manage-ips-and-asns/db/ + */ + +import type { ASN } from "../types/common.js"; +import { PeerCortexError, formatASN } from "../types/common.js"; + +// ── Types ──────────────────────────────────────────────── + +/** IRR source database */ +export type IRRSource = + | "RIPE" + | "RADB" + | "ARIN" + | "APNIC" + | "AFRINIC" + | "LACNIC" + | "NTTCOM" + | "LEVEL3" + | "ALTDB"; + +/** IRR route object */ +export interface IRRRouteObject { + readonly prefix: string; + readonly origin: string; + readonly source: IRRSource; + readonly description: string; + readonly maintainer: string; + readonly lastModified: string; +} + +/** IRR as-set object */ +export interface IRRAsSet { + readonly name: string; + readonly members: ReadonlyArray; + readonly source: IRRSource; + readonly description: string; + readonly maintainer: string; +} + +/** WHOIS record for an ASN or prefix */ +export interface WHOISRecord { + readonly resource: string; + readonly type: "aut-num" | "inetnum" | "inet6num" | "route" | "route6"; + readonly fields: Record>; + readonly source: string; + readonly rawText: string; +} + +// ── Configuration ──────────────────────────────────────── + +interface IRRClientConfig { + readonly defaultSources?: ReadonlyArray; + readonly ripeDbUrl?: string; + readonly timeoutMs?: number; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * IRR / WHOIS query client. + * + * Provides access to Internet Routing Registry data including route objects, + * as-set expansions, and WHOIS records from multiple IRR sources. + * + * @example + * ```typescript + * const client = createIRRClient(); + * const routes = await client.getRouteObjects(13335); + * const asSet = await client.expandAsSet("AS-CLOUDFLARE"); + * ``` + */ +export interface IRRClient { + /** Get all route/route6 objects for an ASN */ + getRouteObjects(asn: ASN): Promise>; + + /** Expand an as-set to its member ASNs (recursive) */ + expandAsSet(asSetName: string): Promise>; + + /** Get the as-set name registered for an ASN */ + getAsSet(asn: ASN): Promise; + + /** Perform a raw WHOIS query */ + whoisQuery(resource: string): Promise; + + /** Look up the IRR registration for a prefix */ + lookupPrefix(prefix: string): Promise>; + + /** Check consistency between IRR and BGP for an ASN */ + checkConsistency( + asn: ASN + ): Promise<{ + registeredPrefixes: ReadonlyArray; + announcedPrefixes: ReadonlyArray; + missingRegistrations: ReadonlyArray; + staleRegistrations: ReadonlyArray; + }>; + + /** Check if the service is reachable */ + healthCheck(): Promise; +} + +/** + * Create a new IRR / WHOIS client. + * + * @param config - Client configuration + * @returns A configured IRR client instance + */ +export function createIRRClient(config: IRRClientConfig = {}): IRRClient { + const ripeDbUrl = + config.ripeDbUrl ?? "https://rest.db.ripe.net"; + const timeoutMs = config.timeoutMs ?? 15000; + const _defaultSources = config.defaultSources ?? ["RIPE", "RADB"]; + + /** + * Query the RIPE DB REST API. + */ + async function queryRIPEDB(path: string): Promise { + const url = `${ripeDbUrl}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `RIPE DB query failed: ${response.status}`, + "SOURCE_UNAVAILABLE", + "irr" + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `IRR query failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "irr", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async getRouteObjects(asn: ASN): Promise> { + // TODO: Query RIPE DB for route objects with origin: AS{asn} + // Use: /search?source=ripe&query-string=AS{asn}&type-filter=route,route6&flags=no-referenced + const _asn = asn; + + try { + const result = await queryRIPEDB<{ + objects: { + object: ReadonlyArray<{ + attributes: { + attribute: ReadonlyArray<{ + name: string; + value: string; + }>; + }; + source: { id: string }; + }>; + }; + }>( + `/search?source=ripe&query-string=${formatASN(asn)}&type-filter=route,route6&flags=no-referenced` + ); + + return result.objects.object.map((obj) => { + const attrs = obj.attributes.attribute; + const getAttr = (name: string) => + attrs.find((a) => a.name === name)?.value ?? ""; + + return { + prefix: getAttr("route") || getAttr("route6"), + origin: getAttr("origin"), + source: obj.source.id as IRRSource, + description: getAttr("descr"), + maintainer: getAttr("mnt-by"), + lastModified: getAttr("last-modified"), + }; + }); + } catch { + return []; // TODO: Handle gracefully, try alternative sources + } + }, + + async expandAsSet(asSetName: string): Promise> { + // TODO: Recursively expand as-set from RIPE DB + // Use: /search?source=ripe&query-string={asSetName}&type-filter=as-set + const _asSetName = asSetName; + return []; // TODO: Implement recursive expansion + }, + + async getAsSet(asn: ASN): Promise { + // TODO: Look up as-set for an ASN + const _asn = asn; + return null; // TODO: Implement + }, + + async whoisQuery(resource: string): Promise { + // TODO: Implement WHOIS query via RIPE DB REST or raw WHOIS + const _resource = resource; + throw new PeerCortexError( + "WHOIS query not yet implemented", + "UNKNOWN", + "irr" + ); + }, + + async lookupPrefix( + prefix: string + ): Promise> { + // TODO: Look up route objects for a specific prefix + const _prefix = prefix; + return []; // TODO: Implement + }, + + async checkConsistency(asn: ASN) { + // TODO: Compare IRR registrations against BGP announcements + const _asn = asn; + return { + registeredPrefixes: [] as ReadonlyArray, + announcedPrefixes: [] as ReadonlyArray, + missingRegistrations: [] as ReadonlyArray, + staleRegistrations: [] as ReadonlyArray, + }; + }, + + async healthCheck(): Promise { + try { + await queryRIPEDB("/search?source=ripe&query-string=AS13335&type-filter=aut-num"); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/ix-traffic.ts b/src/sources/ix-traffic.ts new file mode 100644 index 0000000..bbbaef0 --- /dev/null +++ b/src/sources/ix-traffic.ts @@ -0,0 +1,314 @@ +/** + * @module sources/ix-traffic + * Internet Exchange traffic statistics API client. + * + * Aggregates traffic data from major IXPs including DE-CIX, AMS-IX, and LINX. + * Each IX provides public traffic statistics via their own API format; this + * module normalizes them into a common interface. + * + * @see https://www.de-cix.net/en/locations/statistics + * @see https://www.ams-ix.net/ams/statistics + * @see https://www.linx.net/about/statistics/ + */ + +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +interface IXTrafficClientConfig { + /** Request timeout in milliseconds */ + readonly timeoutMs?: number; +} + +// ── Types ──────────────────────────────────────────────── + +/** Supported IX identifiers */ +export type IXIdentifier = + | "de-cix-frankfurt" + | "de-cix-hamburg" + | "de-cix-munich" + | "de-cix-dusseldorf" + | "ams-ix" + | "linx-lon1" + | "linx-lon2" + | "nlix" + | "six-seattle" + | "any2-los-angeles"; + +/** Traffic data point */ +export interface TrafficDataPoint { + /** Timestamp (ISO 8601) */ + readonly timestamp: string; + /** Ingress traffic in bits per second */ + readonly inBps: number; + /** Egress traffic in bits per second */ + readonly outBps: number; + /** Average traffic in bits per second */ + readonly avgBps: number; + /** Peak traffic in bits per second */ + readonly peakBps: number; +} + +/** Aggregated traffic statistics for an IX */ +export interface IXTrafficStats { + readonly ix: IXIdentifier; + readonly displayName: string; + /** Current peak traffic (bps) */ + readonly currentPeakBps: number; + /** Current average traffic (bps) */ + readonly currentAvgBps: number; + /** Number of connected networks */ + readonly connectedNetworks: number; + /** Total port capacity (bps) */ + readonly totalCapacityBps: number; + /** Historical traffic data points */ + readonly dataPoints: ReadonlyArray; + /** When this data was fetched */ + readonly fetchedAt: string; +} + +/** Port utilization for a member at an IX */ +export interface IXPortUtilization { + readonly ix: IXIdentifier; + readonly asn: number; + readonly portSpeedBps: number; + readonly avgUtilizationPercent: number; + readonly peakUtilizationPercent: number; + readonly lastUpdated: string; +} + +/** Time granularity for traffic queries */ +export type TrafficGranularity = + | "5min" + | "hourly" + | "daily" + | "weekly" + | "monthly"; + +// ── Client Interface ───────────────────────────────────── + +/** + * IX traffic statistics client. + * + * Aggregates public traffic data from major IXPs into a unified interface. + * + * @example + * ```typescript + * const ixTraffic = createIXTrafficClient(); + * + * // Get DE-CIX Frankfurt traffic for the last 30 days + * const stats = await ixTraffic.getTrafficStats("de-cix-frankfurt", { + * period: "30d", + * granularity: "daily", + * }); + * console.log(`Peak: ${(stats.currentPeakBps / 1e12).toFixed(1)} Tbps`); + * ``` + */ +export interface IXTrafficClient { + /** + * Get traffic statistics for an IX. + * + * @param ix - IX identifier + * @param options - Query options (period, granularity) + * @returns Traffic statistics with historical data points + */ + getTrafficStats( + ix: IXIdentifier, + options?: { + readonly period?: string; + readonly granularity?: TrafficGranularity; + } + ): Promise; + + /** + * Get traffic statistics for multiple IXes at once. + * + * @param ixes - Array of IX identifiers + * @returns Map of IX identifier to traffic stats + */ + getMultiIXStats( + ixes: ReadonlyArray + ): Promise>; + + /** + * Get port utilization estimate for a member ASN at an IX. + * + * Note: This data may not be publicly available for all IXes. + * + * @param ix - IX identifier + * @param asn - Member ASN + * @returns Port utilization data + */ + getPortUtilization( + ix: IXIdentifier, + asn: number + ): Promise; + + /** + * List all supported IXes with current summary stats. + * + * @returns Array of IX summaries + */ + listSupportedIXes(): Promise< + ReadonlyArray<{ + readonly id: IXIdentifier; + readonly name: string; + readonly city: string; + readonly country: string; + readonly currentPeakTbps: number; + }> + >; + + /** Check if IX traffic APIs are reachable */ + healthCheck(): Promise; +} + +// ── IX API URLs ────────────────────────────────────────── + +const IX_ENDPOINTS: Record = { + "de-cix-frankfurt": "https://www.de-cix.net/traffic_data/fra.json", + "de-cix-hamburg": "https://www.de-cix.net/traffic_data/ham.json", + "de-cix-munich": "https://www.de-cix.net/traffic_data/muc.json", + "ams-ix": "https://stats-api.ams-ix.net/v1/stats", + "linx-lon1": "https://www.linx.net/api/traffic/lon1", + "linx-lon2": "https://www.linx.net/api/traffic/lon2", +}; + +// ── Client Factory ─────────────────────────────────────── + +/** + * Create a new IX traffic statistics client. + * + * @param config - Client configuration + * @returns A configured IX traffic client instance + */ +export function createIXTrafficClient( + config: IXTrafficClientConfig = {} +): IXTrafficClient { + const timeoutMs = config.timeoutMs ?? 30000; + + /** + * Fetch JSON from a URL with timeout. + */ + async function fetchJson(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `IX traffic API error: ${response.status} for ${url}`, + "SOURCE_UNAVAILABLE", + "peeringdb" // TODO: Add ix_traffic as DataSourceName + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `IX traffic fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "peeringdb", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async getTrafficStats( + ix: IXIdentifier, + options?: { + readonly period?: string; + readonly granularity?: TrafficGranularity; + } + ): Promise { + const endpoint = IX_ENDPOINTS[ix]; + if (!endpoint) { + throw new PeerCortexError( + `Unsupported IX: ${ix}`, + "INVALID_PREFIX", + "peeringdb" + ); + } + + // TODO: Parse period string (e.g., "30d", "12m", "1y") into date range + // TODO: Each IX has a different JSON schema — normalize here + // TODO: Map granularity to IX-specific query params + + const _options = options; + const rawData = await fetchJson>(endpoint); + + // TODO: Parse IX-specific response format into normalized TrafficDataPoint[] + const _rawData = rawData; + + return { + ix, + displayName: ix.replace(/-/g, " ").toUpperCase(), + currentPeakBps: 0, // TODO: Extract from response + currentAvgBps: 0, // TODO: Extract from response + connectedNetworks: 0, + totalCapacityBps: 0, + dataPoints: [], // TODO: Parse time series + fetchedAt: new Date().toISOString(), + }; + }, + + async getMultiIXStats( + ixes: ReadonlyArray + ): Promise> { + // Fetch all IXes in parallel + const results = await Promise.allSettled( + ixes.map((ix) => this.getTrafficStats(ix)) + ); + + return results + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled" + ) + .map((r) => r.value); + }, + + async getPortUtilization( + ix: IXIdentifier, + asn: number + ): Promise { + // TODO: Not all IXes expose per-member utilization publicly + // TODO: May require IX-specific API credentials + const _ix = ix; + const _asn = asn; + return null; + }, + + async listSupportedIXes() { + return [ + { id: "de-cix-frankfurt" as IXIdentifier, name: "DE-CIX Frankfurt", city: "Frankfurt", country: "DE", currentPeakTbps: 0 }, + { id: "de-cix-hamburg" as IXIdentifier, name: "DE-CIX Hamburg", city: "Hamburg", country: "DE", currentPeakTbps: 0 }, + { id: "de-cix-munich" as IXIdentifier, name: "DE-CIX Munich", city: "Munich", country: "DE", currentPeakTbps: 0 }, + { id: "ams-ix" as IXIdentifier, name: "AMS-IX", city: "Amsterdam", country: "NL", currentPeakTbps: 0 }, + { id: "linx-lon1" as IXIdentifier, name: "LINX LON1", city: "London", country: "GB", currentPeakTbps: 0 }, + { id: "linx-lon2" as IXIdentifier, name: "LINX LON2", city: "London", country: "GB", currentPeakTbps: 0 }, + ]; + }, + + async healthCheck(): Promise { + try { + await this.getTrafficStats("de-cix-frankfurt"); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/peeringdb.ts b/src/sources/peeringdb.ts new file mode 100644 index 0000000..9716df7 --- /dev/null +++ b/src/sources/peeringdb.ts @@ -0,0 +1,238 @@ +/** + * @module sources/peeringdb + * PeeringDB API v2 client for network, IX, and facility lookups. + * + * PeeringDB is the freely available, user-maintained database of networks + * and the go-to location for interconnection data. + * + * @see https://www.peeringdb.com/apidocs/ + */ + +import type { + PDBNetwork, + PDBInternetExchange, + PDBFacility, + PDBNetworkIXLan, + PDBNetworkSearchParams, + PDBIXSearchParams, + PeeringDBResponse, +} from "../types/peeringdb.js"; +import type { ASN } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const PEERINGDB_BASE_URL = "https://www.peeringdb.com/api"; + +interface PeeringDBClientConfig { + readonly apiKey?: string; + readonly baseUrl?: string; + readonly timeoutMs?: number; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * PeeringDB API v2 client. + * + * Provides typed access to PeeringDB data including networks, IXs, and facilities. + * Supports optional API key authentication for higher rate limits. + * + * @example + * ```typescript + * const client = createPeeringDBClient({ apiKey: process.env.PEERINGDB_API_KEY }); + * const network = await client.getNetwork(13335); + * console.log(network.name); // "Cloudflare, Inc." + * ``` + */ +export interface PeeringDBClient { + /** Look up a network by ASN */ + getNetwork(asn: ASN): Promise; + + /** Search networks with filters */ + searchNetworks(params: PDBNetworkSearchParams): Promise>; + + /** Get all IX connections for an ASN */ + getNetworkIXLans(asn: ASN): Promise>; + + /** Look up an Internet Exchange by ID */ + getIX(id: number): Promise; + + /** Search Internet Exchanges with filters */ + searchIXs(params: PDBIXSearchParams): Promise>; + + /** Get all participants at an IX */ + getIXParticipants(ixId: number): Promise>; + + /** Look up a facility by ID */ + getFacility(id: number): Promise; + + /** Get all networks at a facility */ + getNetworksAtFacility(facId: number): Promise>; + + /** Find common IXs between two ASNs */ + findCommonIXs(asn1: ASN, asn2: ASN): Promise>; + + /** Check if the API is reachable */ + healthCheck(): Promise; +} + +/** + * Create a new PeeringDB API client. + * + * @param config - Client configuration + * @returns A configured PeeringDB client instance + */ +export function createPeeringDBClient( + config: PeeringDBClientConfig = {} +): PeeringDBClient { + const baseUrl = config.baseUrl ?? PEERINGDB_BASE_URL; + const timeoutMs = config.timeoutMs ?? 15000; + + /** + * Make an authenticated request to the PeeringDB API. + */ + async function request( + endpoint: string, + params: Record = {} + ): Promise> { + const url = new URL(`${baseUrl}/${endpoint}`); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const headers: Record = { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }; + + if (config.apiKey) { + headers["Authorization"] = `Api-Key ${config.apiKey}`; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + headers, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `PeeringDB API error: ${response.status} ${response.statusText}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "peeringdb" + ); + } + + return (await response.json()) as PeeringDBResponse; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `PeeringDB request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "peeringdb", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async getNetwork(asn: ASN): Promise { + const result = await request("net", { asn, depth: 2 }); + if (result.data.length === 0) { + throw new PeerCortexError( + `Network not found for ASN ${asn}`, + "INVALID_ASN", + "peeringdb" + ); + } + return result.data[0]; + }, + + async searchNetworks( + params: PDBNetworkSearchParams + ): Promise> { + const result = await request("net", params as Record); + return result.data; + }, + + async getNetworkIXLans(asn: ASN): Promise> { + const result = await request("netixlan", { asn }); + return result.data; + }, + + async getIX(id: number): Promise { + const result = await request(`ix/${id}`); + if (result.data.length === 0) { + throw new PeerCortexError( + `IX not found: ${id}`, + "PARSE_ERROR", + "peeringdb" + ); + } + return result.data[0]; + }, + + async searchIXs( + params: PDBIXSearchParams + ): Promise> { + const result = await request("ix", params as Record); + return result.data; + }, + + async getIXParticipants(ixId: number): Promise> { + const result = await request("netixlan", { ix_id: ixId }); + return result.data; + }, + + async getFacility(id: number): Promise { + const result = await request(`fac/${id}`); + if (result.data.length === 0) { + throw new PeerCortexError( + `Facility not found: ${id}`, + "PARSE_ERROR", + "peeringdb" + ); + } + return result.data[0]; + }, + + async getNetworksAtFacility(facId: number): Promise> { + // TODO: Implement via netfac -> net lookup + const _facId = facId; + throw new PeerCortexError( + "getNetworksAtFacility not yet implemented", + "UNKNOWN", + "peeringdb" + ); + }, + + async findCommonIXs(asn1: ASN, asn2: ASN): Promise> { + const [ixlans1, ixlans2] = await Promise.all([ + this.getNetworkIXLans(asn1), + this.getNetworkIXLans(asn2), + ]); + + const ixIds1 = new Set(ixlans1.map((ix) => ix.ix_id)); + const commonIXLans = ixlans2.filter((ix) => ixIds1.has(ix.ix_id)); + return commonIXLans.map((ix) => ix.name); + }, + + async healthCheck(): Promise { + try { + await request("net", { asn: 13335 }); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/ripe-atlas.ts b/src/sources/ripe-atlas.ts new file mode 100644 index 0000000..bd1ec8a --- /dev/null +++ b/src/sources/ripe-atlas.ts @@ -0,0 +1,405 @@ +/** + * @module sources/ripe-atlas + * RIPE Atlas API client for network measurements, probes, and anchors. + * + * RIPE Atlas is a global network measurement platform with thousands of probes + * distributed worldwide. This client supports creating measurements, retrieving + * results, and querying probe/anchor metadata. + * + * @see https://atlas.ripe.net/docs/apis/ + */ + +import type { ASN } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const RIPE_ATLAS_BASE_URL = "https://atlas.ripe.net/api/v2"; + +interface RIPEAtlasClientConfig { + /** API key for creating measurements (required for write operations) */ + readonly apiKey?: string; + /** Base URL override */ + readonly baseUrl?: string; + /** Request timeout in milliseconds */ + readonly timeoutMs?: number; +} + +// ── Types ──────────────────────────────────────────────── + +/** RIPE Atlas measurement type */ +export type MeasurementType = + | "ping" + | "traceroute" + | "dns" + | "sslcert" + | "ntp" + | "http"; + +/** RIPE Atlas measurement status */ +export type MeasurementStatus = + | "Specified" + | "Scheduled" + | "Ongoing" + | "Stopped" + | "Forced to stop" + | "No suitable probes" + | "Failed"; + +/** RIPE Atlas probe */ +export interface AtlasProbe { + readonly id: number; + readonly asnV4: number; + readonly asnV6: number; + readonly countryCode: string; + readonly latitude: number; + readonly longitude: number; + readonly status: { + readonly id: number; + readonly name: string; + readonly since: string; + }; + readonly tags: ReadonlyArray; + readonly isAnchor: boolean; +} + +/** RIPE Atlas anchor */ +export interface AtlasAnchor { + readonly id: number; + readonly fqdn: string; + readonly probeId: number; + readonly city: string; + readonly country: string; + readonly company: string; + readonly asnV4: number; + readonly asnV6: number; + readonly isDisabled: boolean; +} + +/** RIPE Atlas measurement definition */ +export interface AtlasMeasurementDef { + readonly type: MeasurementType; + readonly target: string; + readonly description?: string; + readonly af?: 4 | 6; + readonly isOneoff?: boolean; + readonly probesRequested?: number; + readonly probeType?: "area" | "country" | "prefix" | "asn" | "probes"; + readonly probeValue?: string; +} + +/** RIPE Atlas measurement metadata */ +export interface AtlasMeasurement { + readonly id: number; + readonly type: MeasurementType; + readonly target: string; + readonly description: string; + readonly af: 4 | 6; + readonly status: { + readonly id: number; + readonly name: MeasurementStatus; + }; + readonly creationTime: number; + readonly startTime: number; + readonly stopTime: number | null; + readonly participantCount: number; + readonly probesRequested: number; +} + +/** Traceroute hop in an Atlas result */ +export interface TracerouteHop { + readonly hop: number; + readonly result: ReadonlyArray<{ + readonly from?: string; + readonly rtt?: number; + readonly ttl?: number; + readonly err?: string; + }>; +} + +/** Traceroute measurement result */ +export interface TracerouteResult { + readonly probeId: number; + readonly from: string; + readonly dst: string; + readonly timestamp: number; + readonly result: ReadonlyArray; +} + +/** Ping measurement result */ +export interface PingResult { + readonly probeId: number; + readonly from: string; + readonly dst: string; + readonly timestamp: number; + readonly avg: number; + readonly min: number; + readonly max: number; + readonly sent: number; + readonly rcvd: number; + readonly dup: number; +} + +// ── Client Interface ───────────────────────────────────── + +/** + * RIPE Atlas API client. + * + * Provides access to RIPE Atlas measurements, probes, and anchors. + * + * @example + * ```typescript + * const atlas = createRIPEAtlasClient({ apiKey: process.env.RIPE_ATLAS_KEY }); + * + * // Create a one-off traceroute from 50 global probes + * const measurement = await atlas.createMeasurement({ + * type: "traceroute", + * target: "1.1.1.1", + * isOneoff: true, + * probesRequested: 50, + * }); + * + * // Retrieve results once the measurement completes + * const results = await atlas.getTracerouteResults(measurement.id); + * ``` + */ +export interface RIPEAtlasClient { + /** Create a new measurement (requires API key) */ + createMeasurement(def: AtlasMeasurementDef): Promise; + + /** Get measurement metadata by ID */ + getMeasurement(id: number): Promise; + + /** Get traceroute results for a measurement */ + getTracerouteResults(measurementId: number): Promise>; + + /** Get ping results for a measurement */ + getPingResults(measurementId: number): Promise>; + + /** Search for probes by ASN, country, or prefix */ + searchProbes(params: { + readonly asn?: ASN; + readonly countryCode?: string; + readonly prefix?: string; + readonly isAnchor?: boolean; + readonly limit?: number; + }): Promise>; + + /** List all anchors, optionally filtered by country */ + listAnchors(countryCode?: string): Promise>; + + /** Check if the Atlas API is reachable */ + healthCheck(): Promise; +} + +// ── Client Factory ─────────────────────────────────────── + +/** + * Create a new RIPE Atlas API client. + * + * @param config - Client configuration + * @returns A configured RIPE Atlas client instance + */ +export function createRIPEAtlasClient( + config: RIPEAtlasClientConfig = {} +): RIPEAtlasClient { + const baseUrl = config.baseUrl ?? RIPE_ATLAS_BASE_URL; + const apiKey = config.apiKey ?? process.env.RIPE_ATLAS_API_KEY; + const timeoutMs = config.timeoutMs ?? 30000; + + /** + * Make a typed GET request to the Atlas API. + */ + async function get( + path: string, + params: Record = {} + ): Promise { + const url = new URL(`${baseUrl}${path}`); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `RIPE Atlas API error: ${response.status} ${response.statusText}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `RIPE Atlas request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + /** + * Make a typed POST request to the Atlas API. + */ + async function post(path: string, body: unknown): Promise { + if (!apiKey) { + throw new PeerCortexError( + "RIPE Atlas API key required for write operations. Set RIPE_ATLAS_API_KEY.", + "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + const url = new URL(`${baseUrl}${path}`); + url.searchParams.set("key", apiKey); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `RIPE Atlas API error: ${response.status} ${response.statusText}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `RIPE Atlas POST failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async createMeasurement( + def: AtlasMeasurementDef + ): Promise { + // TODO: Map AtlasMeasurementDef to Atlas API v2 measurement creation body + // TODO: Handle probe selection (area, country, prefix, asn) + const body = { + definitions: [ + { + type: def.type, + af: def.af ?? 4, + target: def.target, + description: def.description ?? `PeerCortex ${def.type} to ${def.target}`, + is_oneoff: def.isOneoff ?? true, + }, + ], + probes: [ + { + type: def.probeType ?? "area", + value: def.probeValue ?? "WW", + requested: def.probesRequested ?? 10, + }, + ], + }; + + const result = await post<{ measurements: ReadonlyArray }>( + "/measurements", + body + ); + + // Fetch the full measurement metadata + return this.getMeasurement(result.measurements[0]); + }, + + async getMeasurement(id: number): Promise { + return get(`/measurements/${id}`); + }, + + async getTracerouteResults( + measurementId: number + ): Promise> { + // TODO: Handle pagination for large result sets + return get>( + `/measurements/${measurementId}/results` + ); + }, + + async getPingResults( + measurementId: number + ): Promise> { + // TODO: Handle pagination for large result sets + return get>( + `/measurements/${measurementId}/results` + ); + }, + + async searchProbes(params): Promise> { + // TODO: Map params to Atlas API query parameters + const queryParams: Record = { + asn_v4: params.asn, + country_code: params.countryCode, + prefix_v4: params.prefix, + is_anchor: params.isAnchor, + limit: params.limit ?? 100, + }; + + const result = await get<{ results: ReadonlyArray }>( + "/probes", + queryParams + ); + return result.results; + }, + + async listAnchors(countryCode?: string): Promise> { + const params: Record = { + country: countryCode, + }; + + const result = await get<{ results: ReadonlyArray }>( + "/anchors", + params + ); + return result.results; + }, + + async healthCheck(): Promise { + try { + await get("/status-check"); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/ripe-stat.ts b/src/sources/ripe-stat.ts new file mode 100644 index 0000000..82f6cfd --- /dev/null +++ b/src/sources/ripe-stat.ts @@ -0,0 +1,206 @@ +/** + * @module sources/ripe-stat + * RIPE Stat API client for BGP, routing, and resource information. + * + * RIPE Stat provides a rich set of data calls for Internet resource analysis + * including AS overview, announced prefixes, BGP state, visibility, and RPKI. + * + * @see https://stat.ripe.net/docs/02.data-api/ + */ + +import type { + RIPEStatResponse, + RIPEASOverview, + RIPEAnnouncedPrefixes, + RIPEBGPState, + RIPEBGPUpdates, + RIPELookingGlass, + RIPERPKIValidation, + RIPEVisibility, +} from "../types/bgp.js"; +import type { ASN } from "../types/common.js"; +import { PeerCortexError } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +const RIPE_STAT_BASE_URL = "https://stat.ripe.net/data"; + +interface RIPEStatClientConfig { + readonly sourceApp?: string; + readonly baseUrl?: string; + readonly timeoutMs?: number; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * RIPE Stat API client. + * + * Provides typed access to RIPE Stat data calls for routing analysis, + * BGP monitoring, and RPKI validation. + * + * @example + * ```typescript + * const client = createRIPEStatClient({ sourceApp: "peercortex" }); + * const overview = await client.getASOverview(13335); + * console.log(overview.holder); // "CLOUDFLARENET" + * ``` + */ +export interface RIPEStatClient { + /** Get AS overview (holder, type, block) */ + getASOverview(asn: ASN): Promise; + + /** Get all announced prefixes for an ASN */ + getAnnouncedPrefixes(asn: ASN): Promise; + + /** Get BGP state for a resource (ASN or prefix) */ + getBGPState(resource: string): Promise; + + /** Get BGP updates for a resource over a time period */ + getBGPUpdates( + resource: string, + startTime?: string, + endTime?: string + ): Promise; + + /** Get looking glass data for a resource */ + getLookingGlass(resource: string): Promise; + + /** Validate a prefix-origin pair via RPKI */ + getRPKIValidation(prefix: string, originASN: ASN): Promise; + + /** Get visibility information for a prefix */ + getVisibility(resource: string): Promise; + + /** Check if the API is reachable */ + healthCheck(): Promise; +} + +/** + * Create a new RIPE Stat API client. + * + * @param config - Client configuration + * @returns A configured RIPE Stat client instance + */ +export function createRIPEStatClient( + config: RIPEStatClientConfig = {} +): RIPEStatClient { + const baseUrl = config.baseUrl ?? RIPE_STAT_BASE_URL; + const sourceApp = config.sourceApp ?? "peercortex"; + const timeoutMs = config.timeoutMs ?? 30000; + + /** + * Make a request to the RIPE Stat API. + */ + async function request( + dataCall: string, + params: Record = {} + ): Promise { + const url = new URL(`${baseUrl}/${dataCall}/data.json`); + url.searchParams.set("sourceapp", sourceApp); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `RIPE Stat API error: ${response.status} ${response.statusText}`, + response.status === 429 ? "RATE_LIMITED" : "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + const body = (await response.json()) as RIPEStatResponse; + + if (body.status !== "ok") { + throw new PeerCortexError( + `RIPE Stat data call failed: ${body.data_call_status}`, + "SOURCE_UNAVAILABLE", + "ripe_stat" + ); + } + + return body.data; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `RIPE Stat request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "ripe_stat", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async getASOverview(asn: ASN): Promise { + return request("as-overview", { resource: `AS${asn}` }); + }, + + async getAnnouncedPrefixes(asn: ASN): Promise { + return request("announced-prefixes", { + resource: `AS${asn}`, + }); + }, + + async getBGPState(resource: string): Promise { + return request("bgp-state", { resource }); + }, + + async getBGPUpdates( + resource: string, + startTime?: string, + endTime?: string + ): Promise { + return request("bgp-updates", { + resource, + starttime: startTime, + endtime: endTime, + }); + }, + + async getLookingGlass(resource: string): Promise { + return request("looking-glass", { resource }); + }, + + async getRPKIValidation( + prefix: string, + originASN: ASN + ): Promise { + return request("rpki-validation", { + resource: `AS${originASN}`, + prefix, + }); + }, + + async getVisibility(resource: string): Promise { + return request("visibility", { resource }); + }, + + async healthCheck(): Promise { + try { + await this.getASOverview(13335); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/route-views.ts b/src/sources/route-views.ts new file mode 100644 index 0000000..4282914 --- /dev/null +++ b/src/sources/route-views.ts @@ -0,0 +1,233 @@ +/** + * @module sources/route-views + * Route Views and RIPE RIS client for global routing table data. + * + * Route Views (University of Oregon) and RIPE RIS collect BGP routing data + * from multiple vantage points worldwide. This module uses RIPE Stat as + * the primary API to access this data. + * + * @see https://www.routeviews.org/ + * @see https://ris.ripe.net/ + */ + +import type { RouteViewsEntry, BGPPathAnalysis, BGPVisibilityReport } from "../types/bgp.js"; +import type { ASN } from "../types/common.js"; +import { PeerCortexError, formatASN } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +interface RouteViewsClientConfig { + readonly ripeStatBaseUrl?: string; + readonly timeoutMs?: number; + readonly sourceApp?: string; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * Route Views / RIPE RIS client. + * + * Uses RIPE Stat API as the access layer to Route Views and RIPE RIS + * collector data. Provides routing table lookups, path analysis, and + * visibility reports. + * + * @example + * ```typescript + * const client = createRouteViewsClient(); + * const analysis = await client.analyzePaths("185.1.0.0/24"); + * console.log(analysis.pathDiversity); // Number of unique paths + * ``` + */ +export interface RouteViewsClient { + /** Get routing table entries for a prefix from multiple collectors */ + getRoutingEntries(prefix: string): Promise>; + + /** Analyze BGP path diversity for a prefix */ + analyzePaths(prefix: string): Promise; + + /** Get visibility report for a prefix across collectors */ + getVisibilityReport(prefix: string): Promise; + + /** Get all prefixes originated by an ASN as seen in the global table */ + getOriginatedPrefixes(asn: ASN): Promise>; + + /** Get upstream ASNs for a given ASN based on AS paths */ + getUpstreams(asn: ASN): Promise>; + + /** Check if the data source is reachable */ + healthCheck(): Promise; +} + +/** + * Create a new Route Views / RIPE RIS client. + * + * @param config - Client configuration + * @returns A configured Route Views client instance + */ +export function createRouteViewsClient( + config: RouteViewsClientConfig = {} +): RouteViewsClient { + const ripeStatBaseUrl = + config.ripeStatBaseUrl ?? "https://stat.ripe.net/data"; + const sourceApp = config.sourceApp ?? "peercortex"; + const timeoutMs = config.timeoutMs ?? 30000; + + /** + * Query RIPE Stat API for Route Views / RIS data. + */ + async function queryRIPEStat( + dataCall: string, + params: Record = {} + ): Promise { + const url = new URL(`${ripeStatBaseUrl}/${dataCall}/data.json`); + url.searchParams.set("sourceapp", sourceApp); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new PeerCortexError( + `Route Views query failed: ${response.status}`, + "SOURCE_UNAVAILABLE", + "route_views" + ); + } + + const body = await response.json(); + return (body as { data: T }).data; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `Route Views request failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "route_views", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout); + } + } + + return { + async getRoutingEntries( + prefix: string + ): Promise> { + // TODO: Implement via RIPE Stat looking-glass and bgp-state data calls + // Each RRC provides entries from different vantage points + const _prefix = prefix; + return []; // TODO: Parse and return RouteViewsEntry objects + }, + + async analyzePaths(prefix: string): Promise { + // TODO: Implement path diversity analysis + // 1. Query bgp-state for all paths to the prefix + // 2. Analyze path diversity, upstream ASNs, path lengths + // 3. Optionally use AI to generate analysis text + + const bgpState = await queryRIPEStat<{ + resource: string; + bgp_state: ReadonlyArray<{ + target_prefix: string; + path: ReadonlyArray; + source_id: string; + }>; + }>("bgp-state", { resource: prefix }); + + const paths = bgpState.bgp_state.map((entry) => ({ + asPath: entry.path, + collector: entry.source_id, + peer: "", // TODO: Extract peer from source_id + communities: [] as ReadonlyArray, + })); + + const uniquePaths = new Set( + paths.map((p) => p.asPath.join(",")) + ); + + const upstreamSet = new Set(); + for (const entry of bgpState.bgp_state) { + if (entry.path.length >= 2) { + upstreamSet.add(entry.path[entry.path.length - 2]); + } + } + + const totalPathLength = paths.reduce( + (sum, p) => sum + p.asPath.length, + 0 + ); + + const originASN = + bgpState.bgp_state.length > 0 + ? bgpState.bgp_state[0].path[bgpState.bgp_state[0].path.length - 1] + : 0; + + return { + prefix, + originASN, + paths, + pathDiversity: uniquePaths.size, + upstreamASNs: Array.from(upstreamSet), + avgPathLength: + paths.length > 0 ? totalPathLength / paths.length : 0, + analysis: "", // TODO: Generate AI analysis + }; + }, + + async getVisibilityReport( + prefix: string + ): Promise { + // TODO: Implement via RIPE Stat visibility data call + const _prefix = prefix; + return { + prefix, + originASN: 0, + totalCollectors: 0, + seenByCollectors: 0, + visibilityPercent: 0, + seenPaths: [], + firstSeen: "", + lastSeen: "", + }; + }, + + async getOriginatedPrefixes(asn: ASN): Promise> { + const data = await queryRIPEStat<{ + prefixes: ReadonlyArray<{ prefix: string }>; + }>("announced-prefixes", { resource: formatASN(asn) }); + + return data.prefixes.map((p) => p.prefix); + }, + + async getUpstreams( + asn: ASN + ): Promise> { + // TODO: Implement via AS path analysis + const _asn = asn; + return []; // TODO: Analyze AS paths to determine upstreams + }, + + async healthCheck(): Promise { + try { + await queryRIPEStat("bgp-state", { resource: "1.1.1.0/24" }); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/sources/rpki.ts b/src/sources/rpki.ts new file mode 100644 index 0000000..54a6932 --- /dev/null +++ b/src/sources/rpki.ts @@ -0,0 +1,251 @@ +/** + * @module sources/rpki + * RPKI validator client for ROA lookups and prefix validation. + * + * Supports both local Routinator instances and the RIPE RPKI Validator API. + * Provides ROA lookups, prefix-origin validation, and compliance reporting. + * + * @see https://rpki.readthedocs.io/ + * @see https://routinator.docs.nlnetlabs.nl/ + */ + +import type { + ASN, + RPKIValidation, + RPKIValidationState, + RPKIComplianceReport, + ROA, +} from "../types/common.js"; +import { PeerCortexError, formatASN } from "../types/common.js"; + +// ── Configuration ──────────────────────────────────────── + +interface RPKIClientConfig { + readonly routinatorUrl?: string; + readonly ripeRpkiUrl?: string; + readonly timeoutMs?: number; +} + +// ── Client ─────────────────────────────────────────────── + +/** + * RPKI validator client. + * + * Validates prefix-origin pairs against ROAs using either a local + * Routinator instance or the RIPE RPKI Validator API. + * + * @example + * ```typescript + * const client = createRPKIClient({ routinatorUrl: "http://localhost:8323" }); + * const result = await client.validatePrefix("1.1.1.0/24", 13335); + * console.log(result.state); // "valid" + * ``` + */ +export interface RPKIClient { + /** Validate a prefix-origin pair against ROAs */ + validatePrefix(prefix: string, originASN: ASN): Promise; + + /** Get all ROAs for an ASN */ + getROAsForASN(asn: ASN): Promise>; + + /** Get all ROAs covering a prefix */ + getROAsForPrefix(prefix: string): Promise>; + + /** Generate RPKI compliance report for an ASN */ + generateComplianceReport(asn: ASN): Promise; + + /** Get the full VRP (Validated ROA Payload) list */ + getVRPList(): Promise>; + + /** Check if the RPKI validator is reachable */ + healthCheck(): Promise; +} + +/** + * Create a new RPKI validator client. + * + * Tries Routinator first, falls back to RIPE RPKI Validator API. + * + * @param config - Client configuration + * @returns A configured RPKI client instance + */ +export function createRPKIClient(config: RPKIClientConfig = {}): RPKIClient { + const routinatorUrl = config.routinatorUrl ?? "http://localhost:8323"; + const ripeRpkiUrl = + config.ripeRpkiUrl ?? "https://rpki-validator.ripe.net/api/v1"; + const timeoutMs = config.timeoutMs ?? 15000; + + /** + * Try to query local Routinator, fall back to RIPE RPKI API. + */ + async function queryRPKI( + routinatorPath: string, + ripeFallbackPath: string + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + // Try Routinator first + const routinatorResponse = await fetch( + `${routinatorUrl}${routinatorPath}`, + { + headers: { Accept: "application/json" }, + signal: controller.signal, + } + ); + + if (routinatorResponse.ok) { + return (await routinatorResponse.json()) as T; + } + } catch { + // Routinator not available, fall back to RIPE + } finally { + clearTimeout(timeout); + } + + // Fall back to RIPE RPKI Validator + const controller2 = new AbortController(); + const timeout2 = setTimeout(() => controller2.abort(), timeoutMs); + + try { + const ripeResponse = await fetch( + `${ripeRpkiUrl}${ripeFallbackPath}`, + { + headers: { + Accept: "application/json", + "User-Agent": "PeerCortex/0.1.0", + }, + signal: controller2.signal, + } + ); + + if (!ripeResponse.ok) { + throw new PeerCortexError( + `RPKI validation failed: ${ripeResponse.status}`, + "SOURCE_UNAVAILABLE", + "rpki" + ); + } + + return (await ripeResponse.json()) as T; + } catch (error) { + if (error instanceof PeerCortexError) throw error; + throw new PeerCortexError( + `RPKI query failed: ${error instanceof Error ? error.message : "Unknown error"}`, + "SOURCE_UNAVAILABLE", + "rpki", + error instanceof Error ? error : undefined + ); + } finally { + clearTimeout(timeout2); + } + } + + /** + * Map RPKI validation strings to our typed enum. + */ + function mapValidationState(state: string): RPKIValidationState { + const normalized = state.toLowerCase(); + if (normalized === "valid") return "valid"; + if (normalized === "invalid") return "invalid"; + if (normalized === "not-found" || normalized === "unknown" || normalized === "not_found") { + return "not-found"; + } + return "unknown"; + } + + return { + async validatePrefix( + prefix: string, + originASN: ASN + ): Promise { + // TODO: Implement via Routinator /api/v1/validity/{asn}/{prefix} + // or RIPE Stat rpki-validation data call + + try { + const result = await queryRPKI<{ + validated_route: { + validity: { state: string; description: string }; + route: { origin_asn: string; prefix: string }; + }; + }>( + `/api/v1/validity/AS${originASN}/${prefix}`, + `/validity?asn=${originASN}&prefix=${prefix}` + ); + + return { + prefix, + originASN, + state: mapValidationState( + result.validated_route.validity.state + ), + matchingROAs: [], // TODO: Parse matching ROAs from response + reason: result.validated_route.validity.description, + }; + } catch { + // If both validators fail, return unknown state + return { + prefix, + originASN, + state: "unknown", + matchingROAs: [], + reason: "RPKI validators unavailable", + }; + } + }, + + async getROAsForASN(asn: ASN): Promise> { + // TODO: Query VRP list filtered by ASN + // Routinator: /api/v1/vrps?filter.asn={asn} + const _asn = asn; + return []; // TODO: Implement + }, + + async getROAsForPrefix(prefix: string): Promise> { + // TODO: Query VRP list filtered by prefix + const _prefix = prefix; + return []; // TODO: Implement + }, + + async generateComplianceReport( + asn: ASN + ): Promise { + // TODO: Implement full compliance report + // 1. Get all announced prefixes for the ASN + // 2. Validate each prefix-origin pair + // 3. Calculate coverage percentages + // 4. Generate recommendations + + return { + asn, + name: "", // TODO: Look up from PeeringDB + totalPrefixes: 0, + validPrefixes: 0, + invalidPrefixes: 0, + unknownPrefixes: 0, + coveragePercent: 0, + recommendations: [ + "Create ROAs for all announced prefixes", + "Set appropriate max-length values in ROAs", + "Monitor RPKI validation state continuously", + ], + generatedAt: new Date().toISOString(), + }; + }, + + async getVRPList(): Promise> { + // TODO: Fetch full VRP list from Routinator /api/v1/vrps + return []; // TODO: Implement + }, + + async healthCheck(): Promise { + try { + await this.validatePrefix("1.1.1.0/24", 13335); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/src/types/bgp.ts b/src/types/bgp.ts new file mode 100644 index 0000000..6a66923 --- /dev/null +++ b/src/types/bgp.ts @@ -0,0 +1,352 @@ +/** + * @module types/bgp + * Type definitions for BGP data from RIPE Stat, Route Views, and bgp.he.net. + */ + +// ── RIPE Stat Types ────────────────────────────────────── + +/** RIPE Stat API response wrapper */ +export interface RIPEStatResponse { + readonly status: "ok" | "error"; + readonly status_code: number; + readonly data: T; + readonly query_id: string; + readonly process_time: number; + readonly server_id: string; + readonly build_version: string; + readonly cached: boolean; + readonly data_call_name: string; + readonly data_call_status: string; + readonly messages: ReadonlyArray>; + readonly see_also: ReadonlyArray; + readonly time: string; +} + +/** RIPE Stat — Network Info response */ +export interface RIPENetworkInfo { + readonly asns: ReadonlyArray; + readonly prefix: string; +} + +/** RIPE Stat — AS Overview response */ +export interface RIPEASOverview { + readonly resource: string; + readonly type: string; + readonly block: { + readonly resource: string; + readonly desc: string; + readonly name: string; + }; + readonly holder: string; + readonly announced: boolean; +} + +/** RIPE Stat — Announced Prefixes response */ +export interface RIPEAnnouncedPrefixes { + readonly resource: string; + readonly prefixes: ReadonlyArray<{ + readonly prefix: string; + readonly timelines: ReadonlyArray<{ + readonly starttime: string; + readonly endtime: string; + }>; + }>; + readonly query_starttime: string; + readonly query_endtime: string; +} + +/** RIPE Stat — BGP State response */ +export interface RIPEBGPState { + readonly resource: string; + readonly bgp_state: ReadonlyArray<{ + readonly target_prefix: string; + readonly path: ReadonlyArray; + readonly source_id: string; + readonly community: string; + }>; + readonly nr_routes: number; + readonly query_time: string; +} + +/** RIPE Stat — Looking Glass response */ +export interface RIPELookingGlass { + readonly rrcs: ReadonlyArray<{ + readonly rrc: string; + readonly location: string; + readonly peers: ReadonlyArray<{ + readonly asn_origin: number; + readonly as_path: string; + readonly community: string; + readonly last_updated: string; + readonly prefix: string; + readonly peer: string; + readonly origin: string; + readonly next_hop: string; + readonly latest_time: string; + }>; + }>; +} + +/** RIPE Stat — RIS Peers response */ +export interface RIPERISPeers { + readonly peers: ReadonlyArray<{ + readonly asn: number; + readonly ip: string; + readonly prefix_count: number; + }>; + readonly peer_count: number; +} + +/** RIPE Stat — BGP Updates response */ +export interface RIPEBGPUpdates { + readonly nr_updates: number; + readonly updates: ReadonlyArray<{ + readonly type: string; + readonly timestamp: string; + readonly attrs: { + readonly target_prefix: string; + readonly path: ReadonlyArray; + readonly source_id: string; + readonly community: ReadonlyArray; + }; + }>; + readonly query_starttime: string; + readonly query_endtime: string; +} + +/** RIPE Stat — RPKI Validation response */ +export interface RIPERPKIValidation { + readonly resource: string; + readonly prefix: string; + readonly validating_roas: ReadonlyArray<{ + readonly origin: string; + readonly prefix: string; + readonly max_length: number; + readonly validity: string; + readonly source: string; + }>; + readonly status: string; +} + +/** RIPE Stat — Visibility response */ +export interface RIPEVisibility { + readonly resource: string; + readonly visibilities: ReadonlyArray<{ + readonly probe: { + readonly city: string; + readonly country: string; + readonly name: string; + }; + readonly ris_peers: number; + readonly ris_peers_seeing: number; + }>; +} + +// ── bgp.he.net Scraped Types ──────────────────────────── + +/** bgp.he.net ASN info (scraped) */ +export interface HENetASNInfo { + readonly asn: number; + readonly name: string; + readonly description: string; + readonly country: string; + readonly emailContacts: ReadonlyArray; + readonly abuseContacts: ReadonlyArray; + readonly prefixesOriginated: { + readonly v4: ReadonlyArray; + readonly v6: ReadonlyArray; + }; + readonly peers: ReadonlyArray<{ + readonly asn: number; + readonly name: string; + readonly v4: boolean; + readonly v6: boolean; + }>; + readonly upstreams: ReadonlyArray<{ + readonly asn: number; + readonly name: string; + }>; + readonly downstreams: ReadonlyArray<{ + readonly asn: number; + readonly name: string; + }>; + readonly ixParticipation: ReadonlyArray<{ + readonly ix: string; + readonly speed: string; + readonly ipv4: string; + readonly ipv6: string; + }>; +} + +// ── Route Views Types ──────────────────────────────────── + +/** Route Views / RIPE RIS routing table entry */ +export interface RouteViewsEntry { + readonly prefix: string; + readonly originASN: number; + readonly asPath: ReadonlyArray; + readonly communities: ReadonlyArray; + readonly collector: string; + readonly timestamp: string; +} + +/** Route Views collector information */ +export interface RouteViewsCollector { + readonly name: string; + readonly url: string; + readonly location: string; + readonly peerCount: number; +} + +// ── bgproutes.io Types ────────────────────────────────── + +/** ASPA (Autonomous System Provider Authorization) validation state */ +export type ASPAValidationState = "valid" | "invalid" | "unknown"; + +/** ASPA validation result for a route */ +export interface ASPAValidation { + /** Overall ASPA validation state */ + readonly state: ASPAValidationState; + /** Human-readable description of validation result */ + readonly description: string; + /** Each hop in the AS path with its provider authorization status */ + readonly hopDetails: ReadonlyArray<{ + /** ASN at this position in the path */ + readonly asn: number; + /** Whether this ASN authorizes the next-hop AS as its provider */ + readonly providerAuthorized: boolean; + /** ASPA object source (if any) */ + readonly aspaSource: string | null; + }>; +} + +/** bgproutes.io RIB entry with RPKI and ASPA validation */ +export interface BgpRoutesIoRibEntry { + /** IP prefix */ + readonly prefix: string; + /** Origin ASN */ + readonly originAsn: number; + /** Full AS path */ + readonly asPath: ReadonlyArray; + /** Next-hop IP address */ + readonly nextHop: string; + /** BGP communities */ + readonly communities: ReadonlyArray; + /** Vantage point that observed this route */ + readonly vantagePoint: string; + /** When this entry was last updated */ + readonly lastUpdated: string; + /** RPKI Route Origin Validation status */ + readonly rpkiStatus: "valid" | "invalid" | "not-found" | "unknown"; + /** ASPA validation result */ + readonly aspaValidation: ASPAValidation; + /** MED (Multi-Exit Discriminator) value */ + readonly med: number | null; + /** LOCAL_PREF value (if visible) */ + readonly localPref: number | null; +} + +/** bgproutes.io BGP update message */ +export interface BgpRoutesIoUpdate { + /** Update type */ + readonly type: "announcement" | "withdrawal"; + /** IP prefix */ + readonly prefix: string; + /** Timestamp (ISO 8601) */ + readonly timestamp: string; + /** Origin ASN (null for withdrawals) */ + readonly originAsn: number | null; + /** Full AS path (empty for withdrawals) */ + readonly asPath: ReadonlyArray; + /** Vantage point that observed this update */ + readonly vantagePoint: string; + /** BGP communities */ + readonly communities: ReadonlyArray; + /** RPKI ROV status at time of update */ + readonly rpkiStatus: "valid" | "invalid" | "not-found" | "unknown"; + /** ASPA validation at time of update */ + readonly aspaValidation: ASPAValidation; +} + +/** bgproutes.io vantage point (collector/peer) */ +export interface BgpRoutesIoVantagePoint { + /** Unique identifier */ + readonly id: string; + /** Human-readable name */ + readonly name: string; + /** ASN of the vantage point */ + readonly asn: number; + /** Geographic location */ + readonly location: { + readonly city: string; + readonly country: string; + readonly latitude: number; + readonly longitude: number; + }; + /** Number of prefixes seen */ + readonly prefixCount: number; + /** Whether the vantage point is currently active */ + readonly active: boolean; + /** Last data received timestamp */ + readonly lastSeen: string; +} + +/** bgproutes.io AS-level topology link */ +export interface BgpRoutesIoTopologyLink { + /** Source ASN */ + readonly asnFrom: number; + /** Destination ASN */ + readonly asnTo: number; + /** Relationship type */ + readonly relationship: "provider" | "customer" | "peer" | "sibling"; + /** Number of paths where this link was observed */ + readonly pathCount: number; + /** Whether this link is currently active */ + readonly active: boolean; + /** First observed timestamp */ + readonly firstSeen: string; + /** Last observed timestamp */ + readonly lastSeen: string; +} + +// ── BGP Analysis Types ─────────────────────────────────── + +/** BGP path analysis result */ +export interface BGPPathAnalysis { + readonly prefix: string; + readonly originASN: number; + readonly paths: ReadonlyArray<{ + readonly asPath: ReadonlyArray; + readonly collector: string; + readonly peer: string; + readonly communities: ReadonlyArray; + }>; + readonly pathDiversity: number; + readonly upstreamASNs: ReadonlyArray; + readonly avgPathLength: number; + readonly analysis: string; +} + +/** BGP prefix visibility report */ +export interface BGPVisibilityReport { + readonly prefix: string; + readonly originASN: number; + readonly totalCollectors: number; + readonly seenByCollectors: number; + readonly visibilityPercent: number; + readonly seenPaths: ReadonlyArray>; + readonly firstSeen: string; + readonly lastSeen: string; +} + +/** MOAS (Multiple Origin AS) conflict */ +export interface MOASConflict { + readonly prefix: string; + readonly origins: ReadonlyArray<{ + readonly asn: number; + readonly name: string; + readonly firstSeen: string; + }>; + readonly severity: "critical" | "high" | "medium" | "low"; + readonly description: string; +} diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..196f163 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,313 @@ +/** + * @module types/common + * Shared type definitions used across PeerCortex. + * These types represent the core domain objects for network intelligence. + */ + +// ── ASN Types ──────────────────────────────────────────── + +/** Autonomous System Number (e.g., 13335 for Cloudflare) */ +export type ASN = number; + +/** String representation of an ASN (e.g., "AS13335") */ +export type ASNString = `AS${number}`; + +/** Parse an ASN from various formats */ +export function parseASN(input: string | number): ASN { + if (typeof input === "number") return input; + const cleaned = input.toUpperCase().replace(/^AS/, ""); + const asn = parseInt(cleaned, 10); + if (isNaN(asn) || asn < 0 || asn > 4294967295) { + throw new Error(`Invalid ASN: ${input}`); + } + return asn; +} + +/** Format an ASN number to string notation */ +export function formatASN(asn: ASN): ASNString { + return `AS${asn}` as ASNString; +} + +// ── Prefix Types ───────────────────────────────────────── + +/** IP version */ +export type IPVersion = 4 | 6; + +/** An IP prefix (e.g., "185.1.0.0/24") */ +export interface Prefix { + readonly prefix: string; + readonly ip: string; + readonly cidr: number; + readonly version: IPVersion; +} + +/** Parse a prefix string into a structured object */ +export function parsePrefix(input: string): Prefix { + const parts = input.split("/"); + if (parts.length !== 2) { + throw new Error(`Invalid prefix format: ${input}`); + } + const ip = parts[0]; + const cidr = parseInt(parts[1], 10); + const version: IPVersion = ip.includes(":") ? 6 : 4; + + return { prefix: input, ip, cidr, version }; +} + +// ── Internet Exchange Types ────────────────────────────── + +/** Internet Exchange Point identifier */ +export interface InternetExchange { + readonly id: number; + readonly name: string; + readonly nameLong: string; + readonly city: string; + readonly country: string; + readonly website: string; + readonly peeringdbUrl: string; + readonly participantCount: number; +} + +// ── Network Information ────────────────────────────────── + +/** Peering policy classification */ +export type PeeringPolicy = + | "open" + | "selective" + | "restrictive" + | "no" + | "by-agreement"; + +/** Network type classification */ +export type NetworkType = + | "NSP" + | "Content" + | "Enterprise" + | "Non-Profit" + | "Educational/Research" + | "Route Server" + | "Government" + | "Cable/DSL/ISP" + | "Route Collector"; + +/** Network scope */ +export type NetworkScope = + | "Regional" + | "North America" + | "Asia Pacific" + | "Europe" + | "South America" + | "Africa" + | "Middle East" + | "Global"; + +/** Unified network information gathered from multiple sources */ +export interface NetworkInfo { + readonly asn: ASN; + readonly name: string; + readonly aka: string; + readonly description: string; + readonly website: string; + readonly lookingGlass: string; + readonly peeringPolicy: PeeringPolicy; + readonly networkType: NetworkType; + readonly scope: NetworkScope; + readonly prefixCount4: number; + readonly prefixCount6: number; + readonly ixCount: number; + readonly facilityCount: number; + readonly irr: { + readonly asSet: string; + readonly routeObjects: ReadonlyArray; + }; + readonly rpki: { + readonly roaCount: number; + readonly coveragePercent: number; + readonly validPrefixes: number; + readonly invalidPrefixes: number; + readonly unknownPrefixes: number; + }; + readonly sources: ReadonlyArray; + readonly lastUpdated: string; +} + +// ── Peering Types ──────────────────────────────────────── + +/** A potential peering partner match */ +export interface PeeringMatch { + readonly asn: ASN; + readonly name: string; + readonly peeringPolicy: PeeringPolicy; + readonly commonIXs: ReadonlyArray; + readonly commonFacilities: ReadonlyArray; + readonly score: number; + readonly reason: string; + readonly contactEmail: string; +} + +/** Peering request draft */ +export interface PeeringRequest { + readonly targetASN: ASN; + readonly targetName: string; + readonly ix: string; + readonly subject: string; + readonly body: string; +} + +// ── BGP Types ──────────────────────────────────────────── + +/** BGP route entry */ +export interface BGPRoute { + readonly prefix: string; + readonly originASN: ASN; + readonly asPath: ReadonlyArray; + readonly nextHop: string; + readonly communities: ReadonlyArray; + readonly timestamp: string; +} + +/** BGP anomaly severity levels */ +export type AnomalySeverity = "critical" | "high" | "medium" | "low" | "info"; + +/** BGP anomaly type classification */ +export type AnomalyType = + | "route_leak" + | "bgp_hijack" + | "moas_conflict" + | "path_anomaly" + | "prefix_more_specific" + | "withdrawal_storm" + | "rpki_invalid"; + +/** A detected BGP anomaly */ +export interface BGPAnomaly { + readonly type: AnomalyType; + readonly severity: AnomalySeverity; + readonly prefix: string; + readonly description: string; + readonly affectedASNs: ReadonlyArray; + readonly detectedAt: string; + readonly source: DataSourceName; + readonly details: Record; +} + +// ── RPKI Types ─────────────────────────────────────────── + +/** RPKI validation state */ +export type RPKIValidationState = "valid" | "invalid" | "not-found" | "unknown"; + +/** RPKI Route Origin Authorization */ +export interface ROA { + readonly prefix: string; + readonly maxLength: number; + readonly asn: ASN; + readonly ta: string; + readonly validityStart: string; + readonly validityEnd: string; +} + +/** RPKI validation result for a prefix */ +export interface RPKIValidation { + readonly prefix: string; + readonly originASN: ASN; + readonly state: RPKIValidationState; + readonly matchingROAs: ReadonlyArray; + readonly reason: string; +} + +/** RPKI compliance report for an ASN */ +export interface RPKIComplianceReport { + readonly asn: ASN; + readonly name: string; + readonly totalPrefixes: number; + readonly validPrefixes: number; + readonly invalidPrefixes: number; + readonly unknownPrefixes: number; + readonly coveragePercent: number; + readonly recommendations: ReadonlyArray; + readonly generatedAt: string; +} + +// ── Report Types ───────────────────────────────────────── + +/** Report format options */ +export type ReportFormat = "markdown" | "json" | "text"; + +/** Report type classification */ +export type ReportType = + | "peering_readiness" + | "rpki_compliance" + | "network_comparison" + | "bgp_health" + | "ix_analysis"; + +/** Generated report */ +export interface Report { + readonly type: ReportType; + readonly title: string; + readonly format: ReportFormat; + readonly content: string; + readonly metadata: { + readonly generatedAt: string; + readonly sources: ReadonlyArray; + readonly dataFreshness: string; + }; +} + +// ── Data Source Types ──────────────────────────────────── + +/** Supported data source names */ +export type DataSourceName = + | "peeringdb" + | "ripe_stat" + | "bgp_he" + | "route_views" + | "irr" + | "rpki"; + +/** Health status of a data source */ +export interface DataSourceHealth { + readonly name: DataSourceName; + readonly available: boolean; + readonly latencyMs: number; + readonly lastChecked: string; + readonly error?: string; +} + +// ── Cache Types ────────────────────────────────────────── + +/** Cache entry metadata */ +export interface CacheEntry { + readonly key: string; + readonly data: T; + readonly source: DataSourceName; + readonly cachedAt: string; + readonly expiresAt: string; +} + +// ── Error Types ────────────────────────────────────────── + +/** PeerCortex error codes */ +export type ErrorCode = + | "INVALID_ASN" + | "INVALID_PREFIX" + | "SOURCE_UNAVAILABLE" + | "RATE_LIMITED" + | "CACHE_ERROR" + | "AI_UNAVAILABLE" + | "PARSE_ERROR" + | "TIMEOUT" + | "UNKNOWN"; + +/** Structured error for PeerCortex operations */ +export class PeerCortexError extends Error { + constructor( + message: string, + public readonly code: ErrorCode, + public readonly source?: DataSourceName, + public readonly cause?: Error + ) { + super(message); + this.name = "PeerCortexError"; + } +} diff --git a/src/types/peeringdb.ts b/src/types/peeringdb.ts new file mode 100644 index 0000000..5aaf136 --- /dev/null +++ b/src/types/peeringdb.ts @@ -0,0 +1,240 @@ +/** + * @module types/peeringdb + * Type definitions for PeeringDB API v2 responses. + * @see https://www.peeringdb.com/apidocs/ + */ + +// ── PeeringDB API Envelope ─────────────────────────────── + +/** Standard PeeringDB API response wrapper */ +export interface PeeringDBResponse { + readonly data: ReadonlyArray; + readonly meta: Record; +} + +// ── Organization ───────────────────────────────────────── + +/** PeeringDB Organization (org) object */ +export interface PDBOrganization { + readonly id: number; + readonly name: string; + readonly aka: string; + readonly website: string; + readonly notes: string; + readonly address1: string; + readonly address2: string; + readonly city: string; + readonly state: string; + readonly zipcode: string; + readonly country: string; + readonly latitude: number; + readonly longitude: number; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +// ── Network ────────────────────────────────────────────── + +/** PeeringDB Network (net) object */ +export interface PDBNetwork { + readonly id: number; + readonly org_id: number; + readonly org: PDBOrganization; + readonly name: string; + readonly aka: string; + readonly name_long: string; + readonly website: string; + readonly asn: number; + readonly looking_glass: string; + readonly route_server: string; + readonly irr_as_set: string; + readonly info_type: string; + readonly info_prefixes4: number; + readonly info_prefixes6: number; + readonly info_traffic: string; + readonly info_ratio: string; + readonly info_scope: string; + readonly info_unicast: boolean; + readonly info_multicast: boolean; + readonly info_ipv6: boolean; + readonly info_never_via_route_servers: boolean; + readonly policy_url: string; + readonly policy_general: string; + readonly policy_locations: string; + readonly policy_ratio: boolean; + readonly policy_contracts: string; + readonly netfac_set: ReadonlyArray; + readonly netixlan_set: ReadonlyArray; + readonly poc_set: ReadonlyArray; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +// ── Internet Exchange ──────────────────────────────────── + +/** PeeringDB Internet Exchange (ix) object */ +export interface PDBInternetExchange { + readonly id: number; + readonly org_id: number; + readonly name: string; + readonly name_long: string; + readonly city: string; + readonly country: string; + readonly region_continent: string; + readonly media: string; + readonly notes: string; + readonly proto_unicast: boolean; + readonly proto_multicast: boolean; + readonly proto_ipv6: boolean; + readonly website: string; + readonly url_stats: string; + readonly tech_email: string; + readonly tech_phone: string; + readonly policy_email: string; + readonly policy_phone: string; + readonly fac_set: ReadonlyArray; + readonly ixlan_set: ReadonlyArray; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +/** PeeringDB IX LAN object */ +export interface PDBIXLan { + readonly id: number; + readonly ix_id: number; + readonly name: string; + readonly descr: string; + readonly mtu: number; + readonly dot1q_support: boolean; + readonly rs_asn: number; + readonly arp_sponge: string; + readonly ixpfx_set: ReadonlyArray; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +/** PeeringDB IX Prefix object */ +export interface PDBIXPrefix { + readonly id: number; + readonly ixlan_id: number; + readonly protocol: string; + readonly prefix: string; + readonly in_dfz: boolean; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +// ── Facility ───────────────────────────────────────────── + +/** PeeringDB Facility (fac) object */ +export interface PDBFacility { + readonly id: number; + readonly org_id: number; + readonly name: string; + readonly website: string; + readonly clli: string; + readonly rencode: string; + readonly npanxx: string; + readonly latitude: number; + readonly longitude: number; + readonly notes: string; + readonly city: string; + readonly state: string; + readonly zipcode: string; + readonly country: string; + readonly address1: string; + readonly address2: string; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +/** PeeringDB Network-Facility link */ +export interface PDBNetworkFacility { + readonly id: number; + readonly name: string; + readonly net_id: number; + readonly fac_id: number; + readonly local_asn: number; + readonly city: string; + readonly country: string; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +/** PeeringDB IX-Facility link */ +export interface PDBIXFacility { + readonly id: number; + readonly name: string; + readonly ix_id: number; + readonly fac_id: number; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +// ── Network-IX LAN ─────────────────────────────────────── + +/** PeeringDB Network-IX LAN connection */ +export interface PDBNetworkIXLan { + readonly id: number; + readonly net_id: number; + readonly ix_id: number; + readonly name: string; + readonly ixlan_id: number; + readonly notes: string; + readonly speed: number; + readonly asn: number; + readonly ipaddr4: string | null; + readonly ipaddr6: string | null; + readonly is_rs_peer: boolean; + readonly operational: boolean; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +// ── Point of Contact ───────────────────────────────────── + +/** PeeringDB Point of Contact (poc) object */ +export interface PDBPointOfContact { + readonly id: number; + readonly net_id: number; + readonly role: string; + readonly visible: string; + readonly name: string; + readonly phone: string; + readonly email: string; + readonly url: string; + readonly created: string; + readonly updated: string; + readonly status: string; +} + +// ── Search Types ───────────────────────────────────────── + +/** PeeringDB search parameters for networks */ +export interface PDBNetworkSearchParams { + readonly asn?: number; + readonly name?: string; + readonly name_long?: string; + readonly irr_as_set?: string; + readonly info_type?: string; + readonly policy_general?: string; + readonly country?: string; + readonly city?: string; +} + +/** PeeringDB search parameters for IXs */ +export interface PDBIXSearchParams { + readonly name?: string; + readonly country?: string; + readonly city?: string; + readonly region_continent?: string; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cc7eb07 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +}