Compare commits
No commits in common. "344ee15338c0ec03f0039d37b7722a514b690e55" and "6fb0eb86af8413d129e832b535bd1a011c55ce01" have entirely different histories.
344ee15338
...
6fb0eb86af
18
.gitignore
vendored
18
.gitignore
vendored
@ -35,21 +35,3 @@ coverage/
|
|||||||
# TypeScript incremental
|
# TypeScript incremental
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
ecosystem.config.js
|
ecosystem.config.js
|
||||||
|
|
||||||
# Runtime caches (large binary data — not suitable for git)
|
|
||||||
.roa-cache.json
|
|
||||||
.ripe-stat-cache.json
|
|
||||||
.pdb-source-cache.json
|
|
||||||
.pdb-org-cache.json
|
|
||||||
visitors.json
|
|
||||||
feedback.json
|
|
||||||
hijack-subs.json
|
|
||||||
audit/reports/
|
|
||||||
audit/__pycache__/
|
|
||||||
audit/asn_registry.json
|
|
||||||
audit/latest_report.txt
|
|
||||||
backups/
|
|
||||||
public/index.html.bak
|
|
||||||
public/lia.html
|
|
||||||
server.js.bak*
|
|
||||||
server.js.pre-*
|
|
||||||
|
|||||||
205
CHANGELOG.md
205
CHANGELOG.md
@ -1,159 +1,80 @@
|
|||||||
# PeerCortex Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to PeerCortex are documented here.
|
All notable changes to PeerCortex are documented here.
|
||||||
|
|
||||||
---
|
## [0.5.0] — 2026-03-26
|
||||||
|
|
||||||
## v0.6.6 — 2026-04-02
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Route Server (RS) column in IX table**: Every IX connection now shows whether the network participates in that IXP's route server, directly in the IX Presence table.
|
- **RPKI-based ASPA detection** via Cloudflare RPKI JSON feed — 1,455+ ASPA objects worldwide, cached and refreshed every 10 minutes
|
||||||
- **Contacts & Registration card**: Shows Points of Contact (name, role, email) from PeeringDB along with registration date, last-modified, and RIR handle from RDAP. Named individuals with public emails are flagged as potential B2B leads.
|
- **RFC-compliant ASPA path verification** (draft-ietf-sidrops-aspa-verification-14) — upstream/downstream verification, valley detection, AS_SET flagging, per-hop status
|
||||||
- **Data Sources Timing Panel**: New card showing the response time of every API source queried during the lookup — with colour-coded bars (green < 500 ms, orange < 2 s, red = slow/failed).
|
- **ASPA Readiness Score** (0–100) with four dimensions: ROA coverage, ASPA object existence, provider match completeness, path validation rate
|
||||||
- **Raw JSON Export**: Added "⬇ Raw JSON" link in the network overview. Downloads the full lookup result as a formatted JSON file.
|
- **Provider Audit** — compares RPKI-declared providers vs BGP-detected providers, highlights missing and extra entries with frequency data
|
||||||
- **HQ City in overview**: The network's registered city (from PeeringDB) now appears next to the country flag in the network overview header.
|
- **Network Health Report** — 13 automated checks (Bogon, RPKI ROA, Blocklist, IRR, MANRS, BGP Visibility, Reverse DNS, Abuse Contact, Resource Cert, IX Route Servers, BGP Communities, Geolocation, IRR Object) with traffic-light scoring
|
||||||
|
- **RIPE Atlas probe integration** — shows total probes, connected/disconnected counts, and anchors per ASN
|
||||||
|
- **Route Views** as data source and header navigation link
|
||||||
|
- **bgproutes.io integration** — 3,294+ vantage points, RIB queries, ROV and ASPA status
|
||||||
|
- **RPKI-Declared Providers section** — green badges showing providers from the actual RPKI ASPA object
|
||||||
|
- **Collapsible lists** — "Show X more..." for Detected Upstream Providers (limit 10), Missing/Extra in Provider Audit (limit 5)
|
||||||
|
- **Numerical ASN sorting** across all badge lists and tables
|
||||||
|
- **WHOIS Details** endpoint and dashboard card
|
||||||
|
- **Network Topology** endpoint via CAIDA AS-Relationships
|
||||||
|
- **Peering Partner Finder** — `/api/peers/find` endpoint
|
||||||
|
- **Prefix Detail View** — `/api/prefix/detail` endpoint
|
||||||
|
- **IX Detail View** — `/api/ix/detail` endpoint
|
||||||
|
- **Recent Lookups** with localStorage persistence and quick-click badges
|
||||||
|
- **Network Compare** — side-by-side comparison of two ASNs (common IXPs, shared upstreams, overlapping facilities)
|
||||||
|
- **Copy button** on Recommended ASPA Object code block
|
||||||
|
- **Demo animation** (SVG) in README
|
||||||
|
|
||||||
---
|
### Changed
|
||||||
|
- ASPA detection switched from broken RIPE DB `aut-num` remarks search to Cloudflare RPKI JSON feed (`rpki.cloudflare.com/rpki.json`)
|
||||||
|
- Upstream providers now resolved with AS names via RIPE Stat AS Overview API
|
||||||
## v0.6.4 — 2026-04-02
|
- Version bumped to 0.5.0
|
||||||
|
- Dashboard footer updated with all data sources including Cloudflare RPKI and Route Views
|
||||||
|
- Server User-Agent updated to PeerCortex/0.5.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **IRR Audit**: Switched data source to NLNOG IRR Explorer, which covers all major IRR databases (RIPE, ARIN, APNIC, RPKI-to-IRR). Now shows a per-prefix breakdown with IRR source, RPKI validation status, and an overall assessment badge. Previously showed 0% for correctly registered ASNs.
|
- **Critical: ASPA objects not detected** — networks with valid ASPA (e.g., AS8283 Coloclue, AS6830 Liberty Global) were incorrectly shown as "Not Found" because the old code searched RIPE DB remarks instead of RPKI repositories
|
||||||
- **Service reliability**: Improved automatic recovery from unexpected process crashes — all services now restart automatically without manual intervention.
|
- **SyntaxError in frontend** — CSS routing styles were embedded as a multiline JS string (single quotes don't allow newlines), moved to proper `<style>` block
|
||||||
|
- **Double ASN display** — provider badges showed "AS1031 AS1031" when AS name wasn't available, now shows clean single ASN with resolved name
|
||||||
|
- **Empty brackets in ASPA template** — provider names showed "()" when not resolved, now omitted or fetched via RIPE Stat
|
||||||
|
- **Port conflict on startup** — multiple PM2 instances caused EADDRINUSE, resolved with proper process cleanup
|
||||||
|
- **RPKI per-prefix timeout** — limited batch size to 10 prefixes with 8s fetch timeout to prevent hanging on large ASNs
|
||||||
|
|
||||||
---
|
## [0.4.0] — 2026-03-25
|
||||||
|
|
||||||
## v0.6.3 — 2026-04-02
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Tooltips on all cards**: Hover over any section header to see a plain-language explanation of what data it shows and where it comes from.
|
- Initial public release
|
||||||
|
- Web dashboard with Tokyo Night dark theme
|
||||||
|
- PeeringDB API v2 integration (network profile, IX presence, facilities)
|
||||||
|
- RIPE Stat Data API integration (prefixes, neighbours, visibility, routing status)
|
||||||
|
- bgp.he.net scraping for supplementary BGP data
|
||||||
|
- Per-prefix RPKI validation via RIPE Stat
|
||||||
|
- AS neighbour resolution with names
|
||||||
|
- IPv4/IPv6 route propagation bars with RIS peer visibility
|
||||||
|
- Prefix size distribution badges
|
||||||
|
- MCP Server skeleton with 34 tool definitions
|
||||||
|
- Docker support
|
||||||
|
- Cloudflare Tunnel deployment on Erik server
|
||||||
|
- Live demo at peercortex.org
|
||||||
|
|
||||||
---
|
### Infrastructure
|
||||||
|
- Node.js single-file server (server.js) — zero dependencies beyond Node.js built-ins
|
||||||
## v0.6.2 — 2026-04-01
|
- PM2 process management on Erik (217.154.82.179)
|
||||||
|
- Cloudflare Tunnel via `eo-pulse` tunnel
|
||||||
### Fixed
|
- Domains: peercortex.org, www.peercortex.org, peercortex.context-x.org
|
||||||
- **AS-PATH Visualizer**: Now shows real BGP path data via RIPE RIS looking-glass. Previously showed no data due to an unavailable data endpoint.
|
|
||||||
- **Routing History**: Replaced broken endpoint with RIPE Stat `routing-history` — shows a prefix table with first/last seen dates for all announced prefixes.
|
|
||||||
- **IXP Member List**: Replaced single-IX display with a full IXP picker. All IXPs where the AS is a member appear as buttons; click any to load its member list. Previously only showed one IXP.
|
|
||||||
- **Sources of Trust card**: Moved to the end of the dashboard as intended.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.6.1 — 2026-04-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- New feature cards (BGP Community Decoder, IRR Audit, Routing History, AS-PATH Visualizer, Looking Glass, Hijack Monitor, IXP Member List) now load automatically after every ASN lookup.
|
|
||||||
- Feedback terminal redesigned to match the PeerCortex editorial style — no more green-on-black terminal aesthetic.
|
|
||||||
- Share button replaced with icon-only dropdown (X/Twitter, LinkedIn, Facebook, Copy Link).
|
|
||||||
- Button overlap in bottom-right corner resolved.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.6.0 — 2026-04-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **BGP Community Decoder** — Decodes BGP community values with a built-in database covering RFC-standard, major transit carriers, and IXP communities.
|
|
||||||
- **IRR Audit** — Compares IRR route objects against actual BGP announcements, shows coverage percentage and per-prefix status.
|
|
||||||
- **AS-SET Expander** — Recursively expands AS-SETs (up to 4 levels), lists all member ASNs.
|
|
||||||
- **Routing History** — Shows prefix announcement history over the past 90 days.
|
|
||||||
- **AS-PATH Visualizer** — Visual hop-by-hop AS path diagram from multiple vantage points, origin AS highlighted.
|
|
||||||
- **Looking Glass** — RIPE RIS looking glass for arbitrary prefixes, aggregates paths from up to 15 route collectors.
|
|
||||||
- **BGP Hijack Monitor** — Subscribe any ASN for prefix monitoring; checks every 30 minutes and stores alerts.
|
|
||||||
- **IXP Member List** — Loads PeeringDB member list for any IXP where the queried ASN is present.
|
|
||||||
- **Share Link** — One-click copy of a direct link to any ASN lookup; URL parameter auto-triggers lookup on page load.
|
|
||||||
- **Dark Mode** — Toggle between light and dark theme, preference saved across sessions.
|
|
||||||
- **Changelog page** — Full version history accessible via the navigation bar.
|
|
||||||
- **Unique visitor counter** — Displays privacy-safe UV count in the footer (IP hashing, no raw addresses stored).
|
|
||||||
- **Feedback form** — Submit feedback directly from the dashboard; responses delivered by email.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.5.0 — 2026-03-26
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **RPKI-based ASPA detection** via Cloudflare RPKI JSON feed — 1,500+ ASPA objects, refreshed every 10 minutes.
|
|
||||||
- **RFC-compliant ASPA path verification** (draft-ietf-sidrops-aspa-verification-14) — upstream/downstream verification, valley detection, AS_SET flagging, per-hop status.
|
|
||||||
- **ASPA Readiness Score** (0–100) across four dimensions: ROA coverage, ASPA object presence, provider match completeness, path validation rate.
|
|
||||||
- **Provider Audit** — compares RPKI-declared providers vs BGP-detected providers, highlights gaps.
|
|
||||||
- **Network Health Report** — 13 automated checks with traffic-light scoring (Bogon, RPKI ROA, Blocklist, IRR, MANRS, BGP Visibility, Reverse DNS, Abuse Contact, Resource Cert, IX Route Servers, BGP Communities, Geolocation, IRR Object).
|
|
||||||
- **RIPE Atlas probe integration** — total probes, connected/disconnected counts, anchors per ASN.
|
|
||||||
- **bgproutes.io integration** — 3,000+ vantage points, RIB queries, ROV and ASPA status.
|
|
||||||
- **Network Compare** — side-by-side comparison of two ASNs (common IXPs, shared upstreams, overlapping facilities).
|
|
||||||
- **Recent Lookups** with quick-click history badges.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- ASPA objects not detected — switched from broken RIPE DB remarks search to Cloudflare RPKI JSON feed.
|
|
||||||
- Various frontend rendering bugs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.4.0 — 2026-03-25
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial public release.
|
|
||||||
- ASN lookup dashboard with PeeringDB, RIPE Stat, RIPE Atlas, bgproutes.io, and Cloudflare RPKI integration.
|
|
||||||
- Per-prefix RPKI validation.
|
|
||||||
- AS neighbour resolution with names.
|
|
||||||
- IX presence, facilities, and peering policy display.
|
|
||||||
- Network Compare tool.
|
|
||||||
- Live at peercortex.org.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
| Source | Usage |
|
| Source | API | Usage |
|
||||||
|--------|-------|
|
|--------|-----|-------|
|
||||||
| [PeeringDB](https://www.peeringdb.com/) | Network profiles, IX connections, facilities, peering policy |
|
| [PeeringDB](https://www.peeringdb.com/) | REST API v2 | Network profiles, IX connections, facilities |
|
||||||
| [RIPE Stat](https://stat.ripe.net/) | Prefixes, neighbours, routing history, looking glass, abuse contacts |
|
| [RIPE Stat](https://stat.ripe.net/) | Data API | Prefixes, neighbours, visibility, routing status, abuse contacts |
|
||||||
| [RIPE Atlas](https://atlas.ripe.net/) | Probe and anchor detection per ASN |
|
| [RIPE Atlas](https://atlas.ripe.net/) | REST API v2 | Probe and anchor detection per ASN |
|
||||||
| [bgproutes.io](https://bgproutes.io/) | Vantage point data, RIB queries, ROV/ASPA status |
|
| [Route Views](http://www.routeviews.org/) | Via RIPE Stat | BGP path data, AS relationships |
|
||||||
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation |
|
| [bgp.he.net](https://bgp.he.net/) | HTML scraping | Supplementary BGP data |
|
||||||
| [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases |
|
| [bgproutes.io](https://bgproutes.io/) | REST API v1 | 3,294+ vantage points, RIB data, ROV/ASPA status |
|
||||||
| [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion |
|
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | JSON feed | 1,455+ ASPA objects, ROA validation |
|
||||||
|
| [RIPE DB](https://rest.db.ripe.net/) | REST API | IRR objects, WHOIS data |
|
||||||
## v0.6.5 — 2026-04-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.
|
|
||||||
|
|
||||||
## [0.6.8] — 2026-04-03
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder
|
|
||||||
now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block)
|
|
||||||
- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country
|
|
||||||
now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB)
|
|
||||||
|
|
||||||
### Quality Audit — 2026-04-03 (103 ASNs, dual-run validation)
|
|
||||||
- **0 CRITICAL data errors** across all 103 audited ASNs
|
|
||||||
- **97 PERFECT** — 94% with zero issues
|
|
||||||
- **6 WARNING only** — slow cold-cache on large Tier-1 carriers and minor
|
|
||||||
source disagreement in external registries (not PeerCortex data errors)
|
|
||||||
- All mathematical consistency checks passed 103/103:
|
|
||||||
prefix math · RPKI math · RPKI coverage% · IX dedup · facility counts
|
|
||||||
- Prefix counts cross-validated against RIPE Stat: no deviation >10%
|
|
||||||
- IX connections cross-validated against PeeringDB: no deviation >10%
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
- Daily automated audit introduced: 103 ASNs validated every 24h
|
|
||||||
|
|
||||||
## [0.6.9] — 2026-04-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%),
|
|
||||||
Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%).
|
|
||||||
Hard cap at 5.0 when single transit provider detected.
|
|
||||||
Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
|
|
||||||
- **Route Leak Detection**: Heuristic pattern detection for suspicious routing
|
|
||||||
relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM —
|
|
||||||
pattern-based, not real-time. False positives possible.
|
|
||||||
- **Data Provenance System**: Every data point in the API response now includes
|
|
||||||
a _provenance field: source, validation method (cross-validated / heuristic /
|
|
||||||
computed / single-source), and confidence level (high / medium / experimental).
|
|
||||||
Visible in UI as colour-coded badges: green = validated, orange = indicative.
|
|
||||||
- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude
|
|
||||||
Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report,
|
|
||||||
search_network, get_resilience_score. All responses include provenance metadata.
|
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -1,56 +0,0 @@
|
|||||||
|
|
||||||
============================================================
|
|
||||||
PeerCortex Daily Audit — 2026-03-30 (2026-03-30T05:58:34Z)
|
|
||||||
============================================================
|
|
||||||
Batch: 100 ASNs | PDB key: ACTIVE
|
|
||||||
|
|
||||||
============================================================
|
|
||||||
AUDIT SUMMARY — 2026-03-30
|
|
||||||
============================================================
|
|
||||||
Audited : 100 ASNs
|
|
||||||
Passed : 91 (91%)
|
|
||||||
Failed : 9
|
|
||||||
No PDB : 0 (fac=0 ix=0 is CORRECT — not an error)
|
|
||||||
PDB err : 24 (IX/fac skipped — PDB fetch failed, will retry next run)
|
|
||||||
PDB Key : Active (no rate limits)
|
|
||||||
Trend : 87% → 91% (+4%)
|
|
||||||
|
|
||||||
Timeouts: AS174
|
|
||||||
|
|
||||||
TOP DISCREPANCIES:
|
|
||||||
ASN Field Auth PeerCortex Delta
|
|
||||||
------------------------------------------------------------------
|
|
||||||
AS9002 Neighbours (downstream) 0 2702 2702
|
|
||||||
AS9002 Neighbours (upstream) 0 146 146
|
|
||||||
AS212232 IXPs 0 128 128
|
|
||||||
AS37468 Facilities 0 35 35
|
|
||||||
AS61955 IXPs 0 16 16
|
|
||||||
AS9318 IXPs 0 15 15
|
|
||||||
AS61955 Facilities 0 10 10
|
|
||||||
AS9318 Facilities 0 8 8
|
|
||||||
AS37239 Facilities 4 0 4
|
|
||||||
AS212232 Facilities 0 3 3
|
|
||||||
AS17469 IXPs 0 2 2
|
|
||||||
AS206479 Facilities 0 1 1
|
|
||||||
|
|
||||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
||||||
KNOWN ISSUES (1 ASNs with persistent failures)
|
|
||||||
These remain until the data is correct in PeerCortex.
|
|
||||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
|
||||||
|
|
||||||
AS9002 RETN [OPEN — 2 consecutive failures, first seen: 2026-03-30]
|
|
||||||
▸ Neighbours (upstream):
|
|
||||||
PeerCortex returns 146 but authoritative source shows 0. Likely cause: PeerCortex using stale cached data or querying wrong endpoint.
|
|
||||||
auth=0 pc=146 seen 1x last: 2026-03-30
|
|
||||||
▸ Neighbours (downstream):
|
|
||||||
PeerCortex returns 2702 but authoritative source shows 0. Likely cause: PeerCortex using stale cached data or querying wrong endpoint.
|
|
||||||
auth=0 pc=2702 seen 1x last: 2026-03-30
|
|
||||||
|
|
||||||
|
|
||||||
DATABASE HEALTH:
|
|
||||||
Total tracked ASNs : 100
|
|
||||||
Clean streak : 91 ASNs with 0 consecutive errors
|
|
||||||
Open known issues : 1 ASNs
|
|
||||||
Ever had errors : 57 ASNs
|
|
||||||
|
|
||||||
Report: /opt/peercortex-app/audit/reports/2026-03-30.json
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
448
bio-rd-client.js
448
bio-rd-client.js
@ -1,448 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* bio-rd gRPC client wrapper for PeerCortex.
|
|
||||||
*
|
|
||||||
* Wraps the RoutingInformationService (RIS) from bio-rd.
|
|
||||||
* proto package: bio.ris (cmd/ris/api/ris.proto)
|
|
||||||
* proto package: bio.net (net/api/net.proto)
|
|
||||||
* proto package: bio.route (route/api/route.proto)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const { createRisClient } = require('./bio-rd-client');
|
|
||||||
* const ris = createRisClient('localhost', 4321);
|
|
||||||
* const routes = await ris.dumpRib('router1', '0:0', 0);
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const grpc = require('@grpc/grpc-js');
|
|
||||||
const protoLoader = require('@grpc/proto-loader');
|
|
||||||
|
|
||||||
const PROTO_DIR = path.join(__dirname, 'protos');
|
|
||||||
const RIS_PROTO = path.join(PROTO_DIR, 'cmd/ris/api/ris.proto');
|
|
||||||
|
|
||||||
const LOADER_OPTIONS = {
|
|
||||||
keepCase: true,
|
|
||||||
longs: String, // uint64 fields come back as strings (avoids JS BigInt issues)
|
|
||||||
enums: String, // enum values come back as their string names
|
|
||||||
defaults: true,
|
|
||||||
oneofs: true,
|
|
||||||
includeDirs: [PROTO_DIR],
|
|
||||||
};
|
|
||||||
|
|
||||||
const HIDDEN_REASON_MAP = {
|
|
||||||
HiddenReasonNone: null,
|
|
||||||
HiddenReasonNextHopUnreachable: 'next-hop-unreachable',
|
|
||||||
HiddenReasonFilteredByPolicy: 'filtered-by-policy',
|
|
||||||
HiddenReasonASLoop: 'as-loop',
|
|
||||||
HiddenReasonOurOriginatorID: 'our-originator-id',
|
|
||||||
HiddenReasonClusterLoop: 'cluster-loop',
|
|
||||||
HiddenReasonOTCMismatch: 'otc-mismatch',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ORIGIN_MAP = { 0: 'IGP', 1: 'EGP', 2: 'Incomplete' };
|
|
||||||
|
|
||||||
// ─── IP helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a dotted-decimal or colon-hex IP string to the bio.net.IP proto object.
|
|
||||||
* uint64 fields are passed as strings (matching longs:'String' loader option).
|
|
||||||
*/
|
|
||||||
function ipToProto(ipString) {
|
|
||||||
if (ipString.includes(':')) {
|
|
||||||
return _ipv6ToProto(ipString);
|
|
||||||
}
|
|
||||||
return _ipv4ToProto(ipString);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ipv4ToProto(ipString) {
|
|
||||||
const parts = ipString.split('.').map(Number);
|
|
||||||
if (parts.length !== 4 || parts.some(isNaN)) {
|
|
||||||
throw new Error(`Invalid IPv4 address: ${ipString}`);
|
|
||||||
}
|
|
||||||
const val = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
||||||
return { higher: '0', lower: String(val), version: 'IPv4' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function _ipv6ToProto(ipString) {
|
|
||||||
// Expand :: notation and parse into two uint64 halves
|
|
||||||
const expanded = _expandIPv6(ipString);
|
|
||||||
const groups = expanded.split(':').map(g => parseInt(g, 16));
|
|
||||||
const higher = BigInt(groups[0]) << 48n
|
|
||||||
| BigInt(groups[1]) << 32n
|
|
||||||
| BigInt(groups[2]) << 16n
|
|
||||||
| BigInt(groups[3]);
|
|
||||||
const lower = BigInt(groups[4]) << 48n
|
|
||||||
| BigInt(groups[5]) << 32n
|
|
||||||
| BigInt(groups[6]) << 16n
|
|
||||||
| BigInt(groups[7]);
|
|
||||||
return { higher: String(higher), lower: String(lower), version: 'IPv6' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function _expandIPv6(addr) {
|
|
||||||
if (addr.includes('::')) {
|
|
||||||
const [left, right] = addr.split('::');
|
|
||||||
const leftGroups = left ? left.split(':') : [];
|
|
||||||
const rightGroups = right ? right.split(':') : [];
|
|
||||||
const missing = 8 - leftGroups.length - rightGroups.length;
|
|
||||||
const mid = Array(missing).fill('0');
|
|
||||||
return [...leftGroups, ...mid, ...rightGroups].join(':');
|
|
||||||
}
|
|
||||||
return addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a bio.net.IP proto object back to a human-readable IP string.
|
|
||||||
*/
|
|
||||||
function protoToIp(protoIp) {
|
|
||||||
if (!protoIp) return null;
|
|
||||||
const version = protoIp.version || 'IPv4';
|
|
||||||
const lower = BigInt(protoIp.lower || '0');
|
|
||||||
const higher = BigInt(protoIp.higher || '0');
|
|
||||||
|
|
||||||
if (version === 'IPv4' || version === 0) {
|
|
||||||
const n = Number(lower);
|
|
||||||
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6: reassemble 8 groups of 16 bits from two uint64 values
|
|
||||||
const mask16 = 0xffffn;
|
|
||||||
const groups = [
|
|
||||||
(higher >> 48n) & mask16,
|
|
||||||
(higher >> 32n) & mask16,
|
|
||||||
(higher >> 16n) & mask16,
|
|
||||||
higher & mask16,
|
|
||||||
(lower >> 48n) & mask16,
|
|
||||||
(lower >> 32n) & mask16,
|
|
||||||
(lower >> 16n) & mask16,
|
|
||||||
lower & mask16,
|
|
||||||
];
|
|
||||||
return groups.map(g => g.toString(16)).join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a CIDR string to a bio.net.Prefix proto object.
|
|
||||||
*/
|
|
||||||
function prefixToProto(cidr) {
|
|
||||||
const [ipStr, lenStr] = cidr.split('/');
|
|
||||||
const length = lenStr !== undefined ? parseInt(lenStr, 10) : (ipStr.includes(':') ? 128 : 32);
|
|
||||||
return { address: ipToProto(ipStr), length };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Route decoding ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode a bio.route.Route proto object to a clean JS object.
|
|
||||||
* Handles both DumpRIBReply.route and direct Route messages.
|
|
||||||
*/
|
|
||||||
function decodeRoute(risRoute) {
|
|
||||||
if (!risRoute) return null;
|
|
||||||
|
|
||||||
// DumpRIBReply wraps the route in a .route field
|
|
||||||
const route = risRoute.route || risRoute;
|
|
||||||
if (!route || !route.pfx) return null;
|
|
||||||
|
|
||||||
const pfxAddr = protoToIp(route.pfx.address);
|
|
||||||
const pfxLen = route.pfx.length || 0;
|
|
||||||
const prefix = `${pfxAddr}/${pfxLen}`;
|
|
||||||
|
|
||||||
const paths = (route.paths || []).map(_decodePath);
|
|
||||||
|
|
||||||
// Flatten: return first path's attributes at top level, paths array for multi-path
|
|
||||||
const first = paths[0] || {};
|
|
||||||
return {
|
|
||||||
prefix,
|
|
||||||
nextHop: first.nextHop || null,
|
|
||||||
asPaths: first.asPaths || [],
|
|
||||||
communities: first.communities || [],
|
|
||||||
largeCommunities: first.largeCommunities || [],
|
|
||||||
localPref: first.localPref || 0,
|
|
||||||
med: first.med || 0,
|
|
||||||
origin: first.origin || null,
|
|
||||||
ebgp: first.ebgp || false,
|
|
||||||
hidden: first.hidden || false,
|
|
||||||
hiddenReason: first.hiddenReason || null,
|
|
||||||
paths,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function _decodePath(path) {
|
|
||||||
const hiddenReasonStr = typeof path.hidden_reason === 'string'
|
|
||||||
? path.hidden_reason
|
|
||||||
: Object.keys(HIDDEN_REASON_MAP)[path.hidden_reason] || 'HiddenReasonNone';
|
|
||||||
const hidden = hiddenReasonStr !== 'HiddenReasonNone';
|
|
||||||
const hiddenReason = HIDDEN_REASON_MAP[hiddenReasonStr] || null;
|
|
||||||
|
|
||||||
if (path.type === 'BGP' || path.type === 1) {
|
|
||||||
return _decodeBgpPath(path.bgp_path, hidden, hiddenReason);
|
|
||||||
}
|
|
||||||
if (path.type === 'Static' || path.type === 0) {
|
|
||||||
return _decodeStaticPath(path.static_path, hidden, hiddenReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nextHop: null, asPaths: [], communities: [], largeCommunities: [],
|
|
||||||
localPref: 0, med: 0, origin: null, ebgp: false, hidden, hiddenReason };
|
|
||||||
}
|
|
||||||
|
|
||||||
function _decodeBgpPath(bgp, hidden, hiddenReason) {
|
|
||||||
if (!bgp) return { nextHop: null, asPaths: [], communities: [], largeCommunities: [],
|
|
||||||
localPref: 0, med: 0, origin: null, ebgp: false, hidden, hiddenReason };
|
|
||||||
|
|
||||||
const asPaths = (bgp.as_path || []).flatMap(seg => seg.asns || []).map(Number);
|
|
||||||
|
|
||||||
const communities = (bgp.communities || []).map(c => {
|
|
||||||
const n = Number(c);
|
|
||||||
return `${(n >> 16) & 0xffff}:${n & 0xffff}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const largeCommunities = (bgp.large_communities || []).map(lc =>
|
|
||||||
`${lc.global_administrator}:${lc.data_part1}:${lc.data_part2}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
nextHop: protoToIp(bgp.next_hop),
|
|
||||||
asPaths,
|
|
||||||
communities,
|
|
||||||
largeCommunities,
|
|
||||||
localPref: Number(bgp.local_pref || 0),
|
|
||||||
med: Number(bgp.med || 0),
|
|
||||||
origin: ORIGIN_MAP[bgp.origin] || 'Unknown',
|
|
||||||
ebgp: bgp.ebgp || false,
|
|
||||||
hidden,
|
|
||||||
hiddenReason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function _decodeStaticPath(staticPath, hidden, hiddenReason) {
|
|
||||||
return {
|
|
||||||
nextHop: staticPath ? protoToIp(staticPath.next_hop) : null,
|
|
||||||
asPaths: [], communities: [], largeCommunities: [],
|
|
||||||
localPref: 0, med: 0, origin: 'Static', ebgp: false,
|
|
||||||
hidden, hiddenReason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Client factory ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the RIS proto definition (cached after first call).
|
|
||||||
*/
|
|
||||||
let _packageDef = null;
|
|
||||||
function _loadPackageDef() {
|
|
||||||
if (_packageDef) return _packageDef;
|
|
||||||
_packageDef = protoLoader.loadSync(RIS_PROTO, LOADER_OPTIONS);
|
|
||||||
return _packageDef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a RIS gRPC client.
|
|
||||||
* Returns null if the package definition cannot be loaded.
|
|
||||||
*/
|
|
||||||
function createRisClient(host = 'localhost', port = 4321) {
|
|
||||||
let RisService;
|
|
||||||
try {
|
|
||||||
const packageDef = _loadPackageDef();
|
|
||||||
const proto = grpc.loadPackageDefinition(packageDef);
|
|
||||||
RisService = proto.bio.ris.RoutingInformationService;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[bio-rd] Failed to load proto definition:', err.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const address = `${host}:${port}`;
|
|
||||||
const channelOptions = {
|
|
||||||
'grpc.connect_timeout_ms': 5000,
|
|
||||||
'grpc.initial_reconnect_backoff_ms': 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
const channel = new grpc.Channel(
|
|
||||||
address,
|
|
||||||
grpc.credentials.createInsecure(),
|
|
||||||
channelOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new RisService(address, grpc.credentials.createInsecure(), channelOptions);
|
|
||||||
|
|
||||||
// ── Unary helper ────────────────────────────────────────────────────────────
|
|
||||||
function _unary(method, request) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const deadline = new Date(Date.now() + 5000);
|
|
||||||
client[method](request, { deadline }, (err, response) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stream collector ────────────────────────────────────────────────────────
|
|
||||||
function _collectStream(method, request) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const results = [];
|
|
||||||
const deadline = new Date(Date.now() + 30000);
|
|
||||||
const call = client[method](request, { deadline });
|
|
||||||
call.on('data', chunk => results.push(chunk));
|
|
||||||
call.on('error', reject);
|
|
||||||
call.on('end', () => resolve(results));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Longest Prefix Match for a given prefix.
|
|
||||||
* Returns the best matching route or null.
|
|
||||||
*/
|
|
||||||
async function lpm(router, prefix) {
|
|
||||||
try {
|
|
||||||
const response = await _unary('lPM', {
|
|
||||||
router,
|
|
||||||
vrf: '0:0',
|
|
||||||
pfx: prefixToProto(prefix),
|
|
||||||
});
|
|
||||||
const routes = (response.routes || []).map(decodeRoute).filter(Boolean);
|
|
||||||
return routes[0] || null;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[bio-rd] lpm(${router}, ${prefix}) failed:`, err.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exact prefix lookup.
|
|
||||||
* Returns the matching route or null.
|
|
||||||
*/
|
|
||||||
async function get(router, prefix) {
|
|
||||||
try {
|
|
||||||
const response = await _unary('get', {
|
|
||||||
router,
|
|
||||||
vrf: '0:0',
|
|
||||||
pfx: prefixToProto(prefix),
|
|
||||||
});
|
|
||||||
const routes = (response.routes || []).map(decodeRoute).filter(Boolean);
|
|
||||||
return routes[0] || null;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[bio-rd] get(${router}, ${prefix}) failed:`, err.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all more-specific prefixes covered by the given prefix.
|
|
||||||
* Returns an array of decoded routes.
|
|
||||||
*/
|
|
||||||
async function getLonger(router, prefix) {
|
|
||||||
try {
|
|
||||||
const response = await _unary('getLonger', {
|
|
||||||
router,
|
|
||||||
vrf: '0:0',
|
|
||||||
pfx: prefixToProto(prefix),
|
|
||||||
});
|
|
||||||
return (response.routes || []).map(decodeRoute).filter(Boolean);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[bio-rd] getLonger(${router}, ${prefix}) failed:`, err.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all routers known to this RIS instance.
|
|
||||||
* Returns an array of { name, address } objects.
|
|
||||||
*/
|
|
||||||
async function getRouters() {
|
|
||||||
try {
|
|
||||||
const response = await _unary('getRouters', {});
|
|
||||||
return (response.routers || []).map(r => ({
|
|
||||||
name: r.sys_name || '',
|
|
||||||
address: r.address || '',
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[bio-rd] getRouters() failed:', err.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dump the full RIB for a router/VRF, optionally filtered by origin ASN.
|
|
||||||
* Collects the entire stream and returns an array of decoded routes.
|
|
||||||
*
|
|
||||||
* @param {string} router - Router identifier (e.g. "router1")
|
|
||||||
* @param {string} vrfName - VRF name (e.g. "0:0" for default)
|
|
||||||
* @param {number} originAsn - Filter by originating AS (0 = no filter)
|
|
||||||
*/
|
|
||||||
async function dumpRib(router, vrfName = '0:0', originAsn = 0) {
|
|
||||||
try {
|
|
||||||
const request = {
|
|
||||||
router,
|
|
||||||
vrf: vrfName,
|
|
||||||
afisafi: 'IPv4Unicast',
|
|
||||||
filter: { originating_asn: originAsn || 0 },
|
|
||||||
};
|
|
||||||
const chunks = await _collectStream('dumpRIB', request);
|
|
||||||
return chunks.map(decodeRoute).filter(Boolean);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[bio-rd] dumpRib(${router}) failed:`, err.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Observe real-time RIB updates for a router/VRF.
|
|
||||||
*
|
|
||||||
* @param {string} router - Router identifier
|
|
||||||
* @param {string} vrfName - VRF name (e.g. "0:0")
|
|
||||||
* @param {function} onUpdate - Called with { type: 'add'|'withdraw', route }
|
|
||||||
* @param {function} onError - Called with the error on stream failure
|
|
||||||
* @returns {function} cancel - Call to stop the observation
|
|
||||||
*/
|
|
||||||
function observeRib(router, vrfName = '0:0', onUpdate, onError) {
|
|
||||||
const request = {
|
|
||||||
router,
|
|
||||||
vrf: vrfName,
|
|
||||||
afisafi: 'IPv4Unicast',
|
|
||||||
};
|
|
||||||
|
|
||||||
let call;
|
|
||||||
try {
|
|
||||||
call = client.observeRIB(request);
|
|
||||||
} catch (err) {
|
|
||||||
if (onError) onError(err);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
call.on('data', (update) => {
|
|
||||||
if (!update || !update.route) return;
|
|
||||||
const type = update.advertisement ? 'add' : 'withdraw';
|
|
||||||
const route = decodeRoute(update.route);
|
|
||||||
if (route && onUpdate) onUpdate({ type, route });
|
|
||||||
});
|
|
||||||
|
|
||||||
call.on('error', (err) => {
|
|
||||||
console.warn(`[bio-rd] observeRib(${router}) stream error:`, err.message);
|
|
||||||
if (onError) onError(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
call.on('end', () => {
|
|
||||||
console.log(`[bio-rd] observeRib(${router}) stream ended`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
try { call.cancel(); } catch (_) {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cleanup ─────────────────────────────────────────────────────────────────
|
|
||||||
function close() {
|
|
||||||
try { channel.close(); } catch (_) {}
|
|
||||||
try { client.close(); } catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { lpm, get, getLonger, getRouters, dumpRib, observeRib, close };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createRisClient,
|
|
||||||
ipToProto,
|
|
||||||
prefixToProto,
|
|
||||||
protoToIp,
|
|
||||||
decodeRoute,
|
|
||||||
};
|
|
||||||
12
deploy.sh
12
deploy.sh
@ -1,12 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# PeerCortex safe deploy script — always backup before restart
|
|
||||||
BACKUP_DIR=/opt/peercortex-app/backups
|
|
||||||
mkdir -p $BACKUP_DIR
|
|
||||||
TS=$(date +%Y%m%d_%H%M%S)
|
|
||||||
cp /opt/peercortex-app/server.js $BACKUP_DIR/server.js.$TS
|
|
||||||
cp /opt/peercortex-app/public/index.html $BACKUP_DIR/index.html.$TS
|
|
||||||
# Keep only last 20 backups
|
|
||||||
ls -t $BACKUP_DIR/server.js.* 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
|
||||||
ls -t $BACKUP_DIR/index.html.* 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
|
||||||
echo "[backup] Saved server.js + index.html ($TS)"
|
|
||||||
pm2 restart peercortex
|
|
||||||
265
mcp-server.js
265
mcp-server.js
@ -1,265 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* PeerCortex MCP Server
|
|
||||||
* Exposes PeerCortex API as MCP tools for Claude Desktop / Claude Code.
|
|
||||||
* Communicates via stdio. Every response includes _provenance metadata.
|
|
||||||
*
|
|
||||||
* Usage in claude_desktop_config.json:
|
|
||||||
* { "mcpServers": { "peercortex": { "command": "node", "args": ["/opt/peercortex-app/mcp-server.js"] } } }
|
|
||||||
*/
|
|
||||||
|
|
||||||
const PEERCORTEX_BASE = process.env.PEERCORTEX_BASE || "http://localhost:3101";
|
|
||||||
|
|
||||||
// ── Minimal MCP stdio server (no SDK dependency issues) ──────────────────────
|
|
||||||
const readline = require("readline");
|
|
||||||
const http = require("http");
|
|
||||||
const https = require("https");
|
|
||||||
|
|
||||||
function callApi(path) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const url = PEERCORTEX_BASE + path;
|
|
||||||
const mod = url.startsWith("https") ? https : http;
|
|
||||||
const req = mod.get(url, { headers: { "User-Agent": "PeerCortex-MCP/1.0" } }, (res) => {
|
|
||||||
let data = "";
|
|
||||||
res.on("data", (c) => (data += c));
|
|
||||||
res.on("end", () => {
|
|
||||||
try { resolve(JSON.parse(data)); }
|
|
||||||
catch (e) { reject(new Error("JSON parse error: " + e.message)); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.setTimeout(30000, () => { req.destroy(new Error("timeout")); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summarize a full lookup into a compact MCP-friendly object
|
|
||||||
function summarizeLookup(d) {
|
|
||||||
if (!d || !d.network) return null;
|
|
||||||
const n = d.network || {};
|
|
||||||
const p = d.prefixes || {};
|
|
||||||
const r = d.rpki || {};
|
|
||||||
const ix = d.ix_presence || {};
|
|
||||||
const nb = d.neighbours || {};
|
|
||||||
const rs = d.resilience_score;
|
|
||||||
const rl = d.route_leak;
|
|
||||||
const dq = d.data_quality || {};
|
|
||||||
const prov= d._provenance || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
asn: n.asn,
|
|
||||||
name: n.name,
|
|
||||||
org: n.org_name,
|
|
||||||
website: n.website,
|
|
||||||
rir: n.rir,
|
|
||||||
country: n.country,
|
|
||||||
type: n.type,
|
|
||||||
policy: n.policy,
|
|
||||||
prefixes: {
|
|
||||||
total: p.total,
|
|
||||||
ipv4: p.ipv4,
|
|
||||||
ipv6: p.ipv6,
|
|
||||||
},
|
|
||||||
rpki: {
|
|
||||||
coverage_pct: r.coverage_percent,
|
|
||||||
valid: r.valid,
|
|
||||||
invalid: r.invalid,
|
|
||||||
not_found: r.not_found,
|
|
||||||
},
|
|
||||||
ix_presence: {
|
|
||||||
unique_ixps: ix.unique_ixps,
|
|
||||||
total_connections: ix.total_connections,
|
|
||||||
},
|
|
||||||
neighbours: {
|
|
||||||
upstream_count: nb.upstream_count,
|
|
||||||
downstream_count: nb.downstream_count,
|
|
||||||
peer_count: nb.peer_count,
|
|
||||||
upstreams: (nb.upstreams || []).slice(0, 10).map(u => `AS${u.asn} ${u.name}`),
|
|
||||||
},
|
|
||||||
resilience_score: rs ? {
|
|
||||||
score: rs.score,
|
|
||||||
single_transit_cap: rs.single_transit_cap_applied,
|
|
||||||
breakdown: rs.breakdown,
|
|
||||||
_provenance: rs._provenance,
|
|
||||||
} : null,
|
|
||||||
route_leak: rl ? {
|
|
||||||
detected: rl.detected,
|
|
||||||
patterns: rl.patterns,
|
|
||||||
_provenance: rl._provenance,
|
|
||||||
} : null,
|
|
||||||
data_quality: {
|
|
||||||
score: dq.score,
|
|
||||||
confidence: dq.confidence,
|
|
||||||
},
|
|
||||||
_provenance: prov,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MCP Tool Definitions ──────────────────────────────────────────────────────
|
|
||||||
const TOOLS = [
|
|
||||||
{
|
|
||||||
name: "lookup_asn",
|
|
||||||
description: "Look up comprehensive ASN data including prefixes, RPKI, IX presence, neighbours, resilience score, and route leak detection. All data points include provenance metadata (source, validation method, confidence level).",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
asn: { type: "string", description: "ASN number, e.g. '24940' or 'AS24940'" },
|
|
||||||
},
|
|
||||||
required: ["asn"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "compare_networks",
|
|
||||||
description: "Compare two ASNs side by side — prefixes, RPKI coverage, IX presence, resilience scores, peering policy.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
asn1: { type: "string", description: "First ASN" },
|
|
||||||
asn2: { type: "string", description: "Second ASN" },
|
|
||||||
},
|
|
||||||
required: ["asn1", "asn2"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "get_health_report",
|
|
||||||
description: "Run the PeerCortex 13-point health report for an ASN: RPKI, ASPA, ROA coverage, IRR, MANRS, route visibility, and more.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
asn: { type: "string", description: "ASN number" },
|
|
||||||
},
|
|
||||||
required: ["asn"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "search_network",
|
|
||||||
description: "Search for a network by name or organization. Returns matching ASNs with basic info.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
query: { type: "string", description: "Network name or organization to search for" },
|
|
||||||
},
|
|
||||||
required: ["query"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "get_resilience_score",
|
|
||||||
description: "Get the weighted resilience score (1-10) for an ASN with full breakdown: transit diversity, peering breadth, IXP presence, path redundancy.",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
asn: { type: "string", description: "ASN number" },
|
|
||||||
},
|
|
||||||
required: ["asn"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── MCP Request Handlers ──────────────────────────────────────────────────────
|
|
||||||
async function handleToolCall(name, args) {
|
|
||||||
try {
|
|
||||||
if (name === "lookup_asn") {
|
|
||||||
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
|
|
||||||
const d = await callApi(`/api/lookup?asn=${asnNum}`);
|
|
||||||
const summary = summarizeLookup(d);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === "compare_networks") {
|
|
||||||
const a1 = String(args.asn1).replace(/[^0-9]/g, "");
|
|
||||||
const a2 = String(args.asn2).replace(/[^0-9]/g, "");
|
|
||||||
const [d1, d2] = await Promise.all([
|
|
||||||
callApi(`/api/lookup?asn=${a1}`),
|
|
||||||
callApi(`/api/lookup?asn=${a2}`),
|
|
||||||
]);
|
|
||||||
const comparison = {
|
|
||||||
asn1: summarizeLookup(d1),
|
|
||||||
asn2: summarizeLookup(d2),
|
|
||||||
comparison: {
|
|
||||||
prefixes: { [a1]: d1?.prefixes?.total, [a2]: d2?.prefixes?.total },
|
|
||||||
rpki_coverage: { [a1]: d1?.rpki?.coverage_percent + "%", [a2]: d2?.rpki?.coverage_percent + "%" },
|
|
||||||
unique_ixps: { [a1]: d1?.ix_presence?.unique_ixps, [a2]: d2?.ix_presence?.unique_ixps },
|
|
||||||
upstream_count: { [a1]: d1?.neighbours?.upstream_count, [a2]: d2?.neighbours?.upstream_count },
|
|
||||||
resilience_score: { [a1]: d1?.resilience_score?.score, [a2]: d2?.resilience_score?.score },
|
|
||||||
route_leak: { [a1]: d1?.route_leak?.detected, [a2]: d2?.route_leak?.detected },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(comparison, null, 2) }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === "get_health_report") {
|
|
||||||
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
|
|
||||||
const d = await callApi(`/api/validate?asn=${asnNum}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(d, null, 2) }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === "search_network") {
|
|
||||||
const q = encodeURIComponent(args.query);
|
|
||||||
const d = await callApi(`/api/search?q=${q}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(d, null, 2) }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === "get_resilience_score") {
|
|
||||||
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
|
|
||||||
const d = await callApi(`/api/lookup?asn=${asnNum}`);
|
|
||||||
const rs = d?.resilience_score;
|
|
||||||
const n = d?.network || {};
|
|
||||||
if (!rs) {
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ error: "No resilience data available for AS" + asnNum }) }] };
|
|
||||||
}
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify({
|
|
||||||
asn: asnNum,
|
|
||||||
name: n.name,
|
|
||||||
resilience_score: rs,
|
|
||||||
_note: "Score 1-10. Hard cap at 5.0 if single transit provider detected.",
|
|
||||||
}, null, 2) }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
||||||
} catch (err) {
|
|
||||||
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MCP stdio protocol ────────────────────────────────────────────────────────
|
|
||||||
const rl = readline.createInterface({ input: process.stdin });
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
rl.on("line", async (line) => {
|
|
||||||
buffer += line;
|
|
||||||
let msg;
|
|
||||||
try { msg = JSON.parse(buffer); buffer = ""; }
|
|
||||||
catch (e) { return; } // not complete JSON yet
|
|
||||||
|
|
||||||
const { jsonrpc, id, method, params } = msg;
|
|
||||||
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (method === "initialize") {
|
|
||||||
response = {
|
|
||||||
jsonrpc: "2.0", id,
|
|
||||||
result: {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
serverInfo: { name: "peercortex", version: "0.6.9" },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (method === "tools/list") {
|
|
||||||
response = { jsonrpc: "2.0", id, result: { tools: TOOLS } };
|
|
||||||
} else if (method === "tools/call") {
|
|
||||||
const result = await handleToolCall(params.name, params.arguments || {});
|
|
||||||
response = { jsonrpc: "2.0", id, result };
|
|
||||||
} else if (method === "notifications/initialized") {
|
|
||||||
return; // no response needed
|
|
||||||
} else {
|
|
||||||
response = {
|
|
||||||
jsonrpc: "2.0", id,
|
|
||||||
error: { code: -32601, message: `Method not found: ${method}` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify(response) + "\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("uncaughtException", (err) => {
|
|
||||||
process.stderr.write("PeerCortex MCP error: " + err.message + "\n");
|
|
||||||
});
|
|
||||||
6320
package-lock.json
generated
6320
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "peercortex",
|
"name": "peercortex",
|
||||||
"version": "0.6.5",
|
"version": "0.5.0",
|
||||||
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
"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",
|
"main": "dist/mcp-server/index.js",
|
||||||
"types": "dist/mcp-server/index.d.ts",
|
"types": "dist/mcp-server/index.d.ts",
|
||||||
@ -63,8 +63,6 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.3",
|
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
@ -75,6 +73,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/node-whois": "^2.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
"@typescript-eslint/parser": "^8.18.0",
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
"@vitest/coverage-v8": "^2.1.0",
|
"@vitest/coverage-v8": "^2.1.0",
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package bio.ris;
|
|
||||||
|
|
||||||
import "net/api/net.proto";
|
|
||||||
import "route/api/route.proto";
|
|
||||||
option go_package = "github.com/bio-routing/bio-rd/cmd/ris/api";
|
|
||||||
|
|
||||||
service RoutingInformationService {
|
|
||||||
rpc LPM(LPMRequest) returns (LPMResponse) {};
|
|
||||||
rpc Get(GetRequest) returns (GetResponse) {};
|
|
||||||
rpc GetRouters(GetRoutersRequest) returns (GetRoutersResponse) {};
|
|
||||||
rpc GetLonger(GetLongerRequest) returns (GetLongerResponse) {};
|
|
||||||
rpc ObserveRIB(ObserveRIBRequest) returns (stream RIBUpdate);
|
|
||||||
rpc DumpRIB(DumpRIBRequest) returns (stream DumpRIBReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
message LPMRequest {
|
|
||||||
string router = 1;
|
|
||||||
uint64 vrf_id = 2;
|
|
||||||
string vrf = 4;
|
|
||||||
bio.net.Prefix pfx = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message LPMResponse {
|
|
||||||
repeated bio.route.Route routes = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetRequest {
|
|
||||||
string router = 1;
|
|
||||||
uint64 vrf_id = 2;
|
|
||||||
string vrf = 4;
|
|
||||||
bio.net.Prefix pfx = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetResponse {
|
|
||||||
repeated bio.route.Route routes = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetLongerRequest {
|
|
||||||
string router = 1;
|
|
||||||
uint64 vrf_id = 2;
|
|
||||||
string vrf = 4;
|
|
||||||
bio.net.Prefix pfx = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetLongerResponse {
|
|
||||||
repeated bio.route.Route routes = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ObserveRIBRequest {
|
|
||||||
string router = 1;
|
|
||||||
uint64 vrf_id = 2;
|
|
||||||
string vrf = 4;
|
|
||||||
enum AFISAFI {
|
|
||||||
IPv4Unicast = 0;
|
|
||||||
IPv6Unicast = 1;
|
|
||||||
}
|
|
||||||
AFISAFI afisafi = 3;
|
|
||||||
bool allow_unready_rib = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
message RIBFilter {
|
|
||||||
uint32 originating_asn = 1;
|
|
||||||
uint32 min_length = 2;
|
|
||||||
uint32 max_length = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message RIBUpdate {
|
|
||||||
bool advertisement = 1;
|
|
||||||
bool is_initial_dump = 3;
|
|
||||||
bool end_of_rib = 4;
|
|
||||||
bio.route.Route route = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DumpRIBRequest {
|
|
||||||
string router = 1;
|
|
||||||
uint64 vrf_id = 2;
|
|
||||||
string vrf = 4;
|
|
||||||
enum AFISAFI {
|
|
||||||
IPv4Unicast = 0;
|
|
||||||
IPv6Unicast = 1;
|
|
||||||
}
|
|
||||||
AFISAFI afisafi = 3;
|
|
||||||
RIBFilter filter = 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DumpRIBReply {
|
|
||||||
bio.route.Route route = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetRoutersRequest {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
message Router {
|
|
||||||
string sys_name = 1;
|
|
||||||
repeated uint64 vrf_ids = 2;
|
|
||||||
string address = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GetRoutersResponse {
|
|
||||||
repeated Router routers = 1;
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package bio.net;
|
|
||||||
option go_package = "github.com/bio-routing/bio-rd/net/api";
|
|
||||||
|
|
||||||
message Prefix {
|
|
||||||
IP address = 1;
|
|
||||||
uint32 length = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message IP {
|
|
||||||
uint64 higher = 1;
|
|
||||||
uint64 lower = 2;
|
|
||||||
enum Version {
|
|
||||||
IPv4 = 0;
|
|
||||||
IPv6 = 1;
|
|
||||||
}
|
|
||||||
Version version = 3;
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package bio.bgp;
|
|
||||||
|
|
||||||
import "net/api/net.proto";
|
|
||||||
import "route/api/route.proto";
|
|
||||||
import "protocols/bgp/api/session.proto";
|
|
||||||
option go_package = "github.com/bio-routing/bio-rd/protocols/bgp/api";
|
|
||||||
|
|
||||||
message ListSessionsRequest {
|
|
||||||
SessionFilter filter = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SessionFilter {
|
|
||||||
bio.net.IP neighbor_ip = 1;
|
|
||||||
string vrf_name = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ListSessionsResponse {
|
|
||||||
repeated Session sessions = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DumpRIBRequest {
|
|
||||||
bio.net.IP peer = 1;
|
|
||||||
uint32 afi = 2;
|
|
||||||
uint32 safi = 3;
|
|
||||||
string vrf_name = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
service BgpService {
|
|
||||||
rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse) {}
|
|
||||||
rpc DumpRIBIn(DumpRIBRequest) returns (stream bio.route.Route) {}
|
|
||||||
rpc DumpRIBOut(DumpRIBRequest) returns (stream bio.route.Route) {}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package bio.bgp;
|
|
||||||
|
|
||||||
import "net/api/net.proto";
|
|
||||||
option go_package = "github.com/bio-routing/bio-rd/protocols/bgp/api";
|
|
||||||
|
|
||||||
message Session {
|
|
||||||
bio.net.IP local_address = 1;
|
|
||||||
bio.net.IP neighbor_address = 2;
|
|
||||||
uint32 local_asn = 3;
|
|
||||||
uint32 peer_asn = 4;
|
|
||||||
enum State {
|
|
||||||
Disabled = 0;
|
|
||||||
Idle = 1;
|
|
||||||
Connect = 2;
|
|
||||||
Active = 3;
|
|
||||||
OpenSent = 4;
|
|
||||||
OpenConfirmed = 5;
|
|
||||||
Established = 6;
|
|
||||||
}
|
|
||||||
State status = 5;
|
|
||||||
SessionStats stats = 6;
|
|
||||||
uint64 established_since = 7;
|
|
||||||
string description = 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SessionStats {
|
|
||||||
uint64 messages_in = 1;
|
|
||||||
uint64 messages_out = 2;
|
|
||||||
uint64 flaps = 3;
|
|
||||||
uint64 routes_received = 4;
|
|
||||||
uint64 routes_imported = 5;
|
|
||||||
uint64 routes_exported = 6;
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package bio.route;
|
|
||||||
|
|
||||||
import "net/api/net.proto";
|
|
||||||
option go_package = "github.com/bio-routing/bio-rd/route/api";
|
|
||||||
|
|
||||||
message Route {
|
|
||||||
bio.net.Prefix pfx = 1;
|
|
||||||
repeated Path paths = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Path {
|
|
||||||
enum Type {
|
|
||||||
Static = 0;
|
|
||||||
BGP = 1;
|
|
||||||
}
|
|
||||||
enum HiddenReason {
|
|
||||||
HiddenReasonNone = 0;
|
|
||||||
HiddenReasonNextHopUnreachable = 1;
|
|
||||||
HiddenReasonFilteredByPolicy = 2;
|
|
||||||
HiddenReasonASLoop = 3;
|
|
||||||
HiddenReasonOurOriginatorID = 4;
|
|
||||||
HiddenReasonClusterLoop = 5;
|
|
||||||
HiddenReasonOTCMismatch = 6;
|
|
||||||
}
|
|
||||||
Type type = 1;
|
|
||||||
StaticPath static_path = 2;
|
|
||||||
BGPPath bgp_path = 3;
|
|
||||||
HiddenReason hidden_reason = 4;
|
|
||||||
uint32 time_learned = 5;
|
|
||||||
GRPPath grp_path = 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
message StaticPath {
|
|
||||||
bio.net.IP next_hop = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message GRPPath {
|
|
||||||
bio.net.IP next_hop = 1;
|
|
||||||
map<string,string> meta_data = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message BGPPath {
|
|
||||||
uint32 path_identifier = 1;
|
|
||||||
bio.net.IP next_hop = 2;
|
|
||||||
uint32 local_pref = 3;
|
|
||||||
repeated ASPathSegment as_path = 4;
|
|
||||||
uint32 origin = 5;
|
|
||||||
uint32 med = 6;
|
|
||||||
bool ebgp = 7;
|
|
||||||
uint32 bgp_identifier = 8;
|
|
||||||
bio.net.IP source = 9;
|
|
||||||
repeated uint32 communities = 10;
|
|
||||||
repeated LargeCommunity large_communities = 11;
|
|
||||||
uint32 originator_id = 12;
|
|
||||||
repeated uint32 cluster_list = 13;
|
|
||||||
repeated UnknownPathAttribute unknown_attributes = 14;
|
|
||||||
bool bmp_post_policy = 15;
|
|
||||||
uint32 only_to_customer = 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ASPathSegment {
|
|
||||||
bool as_sequence = 1;
|
|
||||||
repeated uint32 asns = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message LargeCommunity {
|
|
||||||
uint32 global_administrator = 1;
|
|
||||||
uint32 data_part1 = 2;
|
|
||||||
uint32 data_part2 = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message UnknownPathAttribute {
|
|
||||||
bool optional = 1;
|
|
||||||
bool transitive = 2;
|
|
||||||
bool partial = 3;
|
|
||||||
uint32 type_code = 4;
|
|
||||||
bytes value = 5;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
2340
public/index.html
2340
public/index.html
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
488
public/lia.html
488
public/lia.html
@ -1,488 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Lia's Paradise — RIPE Atlas Coverage Explorer</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
:root{--bg:#0f0f1a;--card:#1a1b26;--border:#2a2b3d;--border-light:#363750;--text:#e2e8f0;--text-dim:#94a3b8;--muted:#64748b;--dim:#475569;--purple:#a78bfa;--blue:#60a5fa;--green:#4ade80;--orange:#fbbf24;--red:#f87171;--cyan:#22d3ee;--pink:#f472b6}
|
|
||||||
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
||||||
.header{background:linear-gradient(135deg,#1a1b26 0%,#1e1f30 100%);border-bottom:1px solid var(--border);padding:1.5rem 2rem;text-align:center}
|
|
||||||
.header h1{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--pink),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.25rem}
|
|
||||||
.header p{color:var(--text-dim);font-size:.85rem}
|
|
||||||
.easter-egg{font-size:.65rem;color:var(--dim);margin-top:.25rem;font-style:italic}
|
|
||||||
.controls{max-width:1400px;margin:1.5rem auto;padding:0 1.5rem;display:flex;gap:1rem;flex-wrap:wrap;align-items:center}
|
|
||||||
.search-box{flex:1;min-width:200px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.7rem 1rem;font-size:.9rem;color:var(--text);font-family:inherit;outline:none}
|
|
||||||
.search-box:focus{border-color:var(--purple)}
|
|
||||||
.search-box::placeholder{color:var(--dim)}
|
|
||||||
.rir-tabs{display:flex;gap:.4rem;flex-wrap:wrap}
|
|
||||||
.rir-tab{padding:.5rem 1rem;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text-dim);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit}
|
|
||||||
.rir-tab:hover{border-color:var(--purple);color:var(--text)}
|
|
||||||
.rir-tab.active{background:linear-gradient(135deg,#5b21b6,#7c3aed);color:#fff;border-color:#7c3aed}
|
|
||||||
.export-btn{padding:.5rem 1.2rem;border-radius:8px;border:1px solid var(--cyan);background:transparent;color:var(--cyan);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit}
|
|
||||||
.export-btn:hover{background:var(--cyan);color:var(--bg)}
|
|
||||||
.stats-bar{max-width:1400px;margin:.75rem auto;padding:0 1.5rem;display:flex;gap:1.5rem;flex-wrap:wrap}
|
|
||||||
.stat-pill{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.5rem 1rem;display:flex;align-items:center;gap:.5rem}
|
|
||||||
.stat-pill .num{font-size:1.1rem;font-weight:800}
|
|
||||||
.stat-pill .label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
|
|
||||||
.content{max-width:1400px;margin:1rem auto;padding:0 1.5rem}
|
|
||||||
.loading{text-align:center;padding:3rem;color:var(--muted);font-size:.9rem}
|
|
||||||
.loading .spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;margin-right:.5rem;vertical-align:middle}
|
|
||||||
@keyframes spin{to{transform:rotate(360deg)}}
|
|
||||||
.country-section{margin-bottom:1.5rem}
|
|
||||||
.country-header{display:flex;align-items:center;gap:.75rem;padding:.6rem 1rem;background:var(--card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .2s;margin-bottom:.5rem}
|
|
||||||
.country-header:hover{border-color:var(--purple)}
|
|
||||||
.country-flag{font-size:1.2rem}
|
|
||||||
.country-name{font-weight:700;font-size:.95rem;flex:1}
|
|
||||||
.country-count{font-size:.75rem;color:var(--muted)}
|
|
||||||
.country-badge{padding:.2rem .6rem;border-radius:6px;font-size:.7rem;font-weight:700}
|
|
||||||
.badge-red{background:#f8717118;color:var(--red);border:1px solid #f8717130}
|
|
||||||
.badge-green{background:#4ade8018;color:var(--green);border:1px solid #4ade8030}
|
|
||||||
.badge-orange{background:#fbbf2418;color:var(--orange);border:1px solid #fbbf2430}
|
|
||||||
.asn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.4rem;padding:0 0 .5rem 0}
|
|
||||||
.asn-row{display:flex;align-items:center;gap:.65rem;padding:.5rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:.8rem;transition:all .15s;cursor:pointer}
|
|
||||||
.asn-row:hover{border-color:var(--purple);transform:translateY(-1px)}
|
|
||||||
.asn-num{font-weight:700;color:var(--purple);min-width:80px;font-family:'JetBrains Mono',monospace}
|
|
||||||
.asn-name{flex:1;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.asn-type{font-size:.65rem;padding:.15rem .4rem;border-radius:4px;background:var(--card);color:var(--dim);border:1px solid var(--border)}
|
|
||||||
.no-probe{color:var(--red);font-size:.65rem;font-weight:600}
|
|
||||||
.has-probe{color:var(--green);font-size:.65rem;font-weight:600}
|
|
||||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
|
||||||
.summary-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;text-align:center}
|
|
||||||
.summary-card .rir-name{font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem}
|
|
||||||
.summary-card .big-num{font-size:2rem;font-weight:800}
|
|
||||||
.summary-card .sub{font-size:.7rem;color:var(--dim);margin-top:.25rem}
|
|
||||||
.progress-bar{height:6px;background:var(--bg);border-radius:3px;margin-top:.5rem;overflow:hidden}
|
|
||||||
.progress-fill{height:100%;border-radius:3px;transition:width .5s ease}
|
|
||||||
.hidden{display:none}
|
|
||||||
.footer{text-align:center;padding:2rem;color:var(--dim);font-size:.7rem;margin-top:2rem;border-top:1px solid var(--border)}
|
|
||||||
.footer a{color:var(--purple);text-decoration:none}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>Lia's Paradise</h1>
|
|
||||||
<p>RIPE Atlas Coverage Explorer — Networks without Atlas Probes, Anchors & Software Probes</p>
|
|
||||||
<div class="easter-egg">For Lia, who makes the Internet measurable — one probe at a time</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<input type="text" class="search-box" id="searchInput" placeholder="Search by country, ASN, or network name..." oninput="filterResults()">
|
|
||||||
<div class="rir-tabs" id="rirTabs">
|
|
||||||
<button class="rir-tab active" onclick="switchRIR('all',this)">All RIRs</button>
|
|
||||||
<button class="rir-tab" onclick="switchRIR('ripencc',this)">RIPE NCC</button>
|
|
||||||
<button class="rir-tab" onclick="switchRIR('arin',this)">ARIN</button>
|
|
||||||
<button class="rir-tab" onclick="switchRIR('apnic',this)">APNIC</button>
|
|
||||||
<button class="rir-tab" onclick="switchRIR('lacnic',this)">LACNIC</button>
|
|
||||||
<button class="rir-tab" onclick="switchRIR('afrinic',this)">AFRINIC</button>
|
|
||||||
</div>
|
|
||||||
<button class="export-btn" onclick="exportPDF()">Export PDF</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="max-width:1400px;margin:.75rem auto;padding:0 1.5rem">
|
|
||||||
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1rem 1.5rem">
|
|
||||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
|
||||||
<div style="font-size:.8rem;font-weight:600;color:var(--pink)">FILE LOOKUP</div>
|
|
||||||
<div style="font-size:.75rem;color:var(--dim)">Upload a file with ASNs or company names — we'll check probe coverage for each</div>
|
|
||||||
<div style="flex:1"></div>
|
|
||||||
<label style="padding:.45rem 1rem;border-radius:8px;border:1px solid var(--pink);background:transparent;color:var(--pink);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:.4rem" onmouseenter="this.style.background='var(--pink)';this.style.color='var(--bg)'" onmouseleave="this.style.background='transparent';this.style.color='var(--pink)'">
|
|
||||||
<span>Upload File</span>
|
|
||||||
<input type="file" id="fileUpload" accept=".csv,.txt,.pdf,.xls,.xlsx,.doc,.docx" style="display:none" onchange="handleFileUpload(this)">
|
|
||||||
</label>
|
|
||||||
<span id="fileStatus" style="font-size:.7rem;color:var(--dim)"></span>
|
|
||||||
</div>
|
|
||||||
<div id="fileResults" class="hidden" style="margin-top:1rem"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-bar" id="statsBar"></div>
|
|
||||||
|
|
||||||
<div id="summaryGrid" class="summary-grid" style="max-width:1400px;margin:1rem auto;padding:0 1.5rem"></div>
|
|
||||||
|
|
||||||
<div class="content" id="mainContent">
|
|
||||||
<div class="loading"><span class="spinner"></span>Loading Atlas coverage data across all RIRs... This may take a moment.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<a href="/">Back to PeerCortex</a> · Data from <a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a> & <a href="https://www.peeringdb.com" target="_blank">PeeringDB</a>
|
|
||||||
<br>Made with love for the Atlas community
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
var allData = [];
|
|
||||||
var currentRIR = 'all';
|
|
||||||
var countryFlags = {};
|
|
||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
|
||||||
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
||||||
|
|
||||||
// Country code to flag emoji
|
|
||||||
function flag(cc) {
|
|
||||||
if (!cc || cc.length !== 2) return '';
|
|
||||||
return String.fromCodePoint(...cc.toUpperCase().split('').map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
|
|
||||||
}
|
|
||||||
|
|
||||||
// RIR mapping by country
|
|
||||||
var rirMap = {
|
|
||||||
ripencc: new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IS','IE','IT','LV','LT','LU','MT','NL','NO','PL','PT','RO','SK','SI','ES','SE','CH','GB','UA','RU','TR','GE','AZ','AM','MD','BY','RS','BA','ME','MK','AL','XK','LI','MC','SM','VA','AD','FO','GL','AX','GG','JE','IM','BH','IQ','IR','IL','JO','KW','LB','OM','PS','QA','SA','SY','AE','YE','DZ','EG','LY','MA','TN','EH']),
|
|
||||||
arin: new Set(['US','CA','PR','VI','GU','AS','MP','MH','FM','PW','UM']),
|
|
||||||
apnic: new Set(['AU','NZ','JP','KR','CN','HK','MO','TW','IN','BD','PK','LK','NP','BT','MV','AF','MM','TH','VN','LA','KH','MY','SG','ID','PH','BN','TL','PG','FJ','WS','TO','VU','SB','KI','NR','TV','MN','KZ','KG','TJ','TM','UZ']),
|
|
||||||
lacnic: new Set(['MX','GT','BZ','SV','HN','NI','CR','PA','CO','VE','EC','PE','BO','CL','AR','UY','PY','BR','GY','SR','GF','CU','JM','HT','DO','TT','BB','AG','DM','GD','KN','LC','VC','BS','CW','AW','SX','BQ','TC','KY','BM']),
|
|
||||||
afrinic: new Set(['ZA','NG','KE','GH','TZ','UG','ET','RW','SN','CI','CM','CD','CG','GA','AO','MZ','ZW','ZM','MW','BW','NA','SZ','LS','MG','MU','SC','DJ','ER','SO','SD','SS','TD','NE','ML','BF','GW','GN','SL','LR','TG','BJ','CF','GQ']),
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRIR(cc) {
|
|
||||||
if (!cc) return 'unknown';
|
|
||||||
for (var r in rirMap) { if (rirMap[r].has(cc.toUpperCase())) return r; }
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
// Fetch PeeringDB networks with IX presence (active networks)
|
|
||||||
var resp = await fetch('/api/lia/coverage');
|
|
||||||
var d = await resp.json();
|
|
||||||
if (d.error) throw new Error(d.error);
|
|
||||||
allData = d.networks || [];
|
|
||||||
renderAll();
|
|
||||||
} catch (e) {
|
|
||||||
$('mainContent').innerHTML = '<div class="loading" style="color:var(--red)">Failed to load: ' + escHtml(e.message) + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAll() {
|
|
||||||
renderSummary();
|
|
||||||
renderStats();
|
|
||||||
renderCountries();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummary() {
|
|
||||||
var rir_stats = { ripencc: {total:0,noProbe:0}, arin: {total:0,noProbe:0}, apnic: {total:0,noProbe:0}, lacnic: {total:0,noProbe:0}, afrinic: {total:0,noProbe:0} };
|
|
||||||
var rirLabels = { ripencc:'RIPE NCC', arin:'ARIN', apnic:'APNIC', lacnic:'LACNIC', afrinic:'AFRINIC' };
|
|
||||||
var rirColors = { ripencc:'var(--blue)', arin:'var(--green)', apnic:'var(--orange)', lacnic:'var(--pink)', afrinic:'var(--cyan)' };
|
|
||||||
|
|
||||||
allData.forEach(function(n) {
|
|
||||||
var r = getRIR(n.country);
|
|
||||||
if (rir_stats[r]) {
|
|
||||||
rir_stats[r].total++;
|
|
||||||
if (!n.has_probe) rir_stats[r].noProbe++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var h = '';
|
|
||||||
for (var r in rir_stats) {
|
|
||||||
var s = rir_stats[r];
|
|
||||||
var covPct = s.total > 0 ? ((s.total - s.noProbe) / s.total * 100).toFixed(1) : '0';
|
|
||||||
var col = rirColors[r];
|
|
||||||
h += '<div class="summary-card">';
|
|
||||||
h += '<div class="rir-name">' + rirLabels[r] + '</div>';
|
|
||||||
h += '<div class="big-num" style="color:' + col + '">' + s.noProbe.toLocaleString() + '</div>';
|
|
||||||
h += '<div class="sub">of ' + s.total.toLocaleString() + ' networks without a probe</div>';
|
|
||||||
h += '<div class="progress-bar"><div class="progress-fill" style="width:' + covPct + '%;background:' + col + '"></div></div>';
|
|
||||||
h += '<div class="sub" style="margin-top:.25rem">' + covPct + '% coverage</div>';
|
|
||||||
h += '</div>';
|
|
||||||
}
|
|
||||||
$('summaryGrid').innerHTML = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats() {
|
|
||||||
var filtered = getFiltered();
|
|
||||||
var noProbe = filtered.filter(function(n) { return !n.has_probe; }).length;
|
|
||||||
var withProbe = filtered.length - noProbe;
|
|
||||||
var countries = new Set(filtered.map(function(n) { return n.country; })).size;
|
|
||||||
|
|
||||||
var h = '';
|
|
||||||
h += '<div class="stat-pill"><span class="num" style="color:var(--red)">' + noProbe.toLocaleString() + '</span><span class="label">Without Probe</span></div>';
|
|
||||||
h += '<div class="stat-pill"><span class="num" style="color:var(--green)">' + withProbe.toLocaleString() + '</span><span class="label">With Probe</span></div>';
|
|
||||||
h += '<div class="stat-pill"><span class="num" style="color:var(--purple)">' + filtered.length.toLocaleString() + '</span><span class="label">Total Networks</span></div>';
|
|
||||||
h += '<div class="stat-pill"><span class="num" style="color:var(--cyan)">' + countries + '</span><span class="label">Countries</span></div>';
|
|
||||||
$('statsBar').innerHTML = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFiltered() {
|
|
||||||
var q = ($('searchInput').value || '').toLowerCase();
|
|
||||||
return allData.filter(function(n) {
|
|
||||||
if (currentRIR !== 'all' && getRIR(n.country) !== currentRIR) return false;
|
|
||||||
if (q) {
|
|
||||||
var haystack = (n.name + ' ' + n.country + ' ' + n.country_name + ' AS' + n.asn + ' ' + (n.info_type || '')).toLowerCase();
|
|
||||||
if (haystack.indexOf(q) < 0) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCountries() {
|
|
||||||
var filtered = getFiltered();
|
|
||||||
|
|
||||||
// Group by country
|
|
||||||
var byCountry = {};
|
|
||||||
filtered.forEach(function(n) {
|
|
||||||
var cc = n.country || 'XX';
|
|
||||||
if (!byCountry[cc]) byCountry[cc] = { name: n.country_name || cc, networks: [] };
|
|
||||||
byCountry[cc].networks.push(n);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort countries by number of networks without probes (descending)
|
|
||||||
var countries = Object.keys(byCountry).sort(function(a, b) {
|
|
||||||
var aNo = byCountry[a].networks.filter(function(n) { return !n.has_probe; }).length;
|
|
||||||
var bNo = byCountry[b].networks.filter(function(n) { return !n.has_probe; }).length;
|
|
||||||
return bNo - aNo;
|
|
||||||
});
|
|
||||||
|
|
||||||
var h = '';
|
|
||||||
countries.forEach(function(cc) {
|
|
||||||
var c = byCountry[cc];
|
|
||||||
var noProbe = c.networks.filter(function(n) { return !n.has_probe; });
|
|
||||||
var withProbe = c.networks.filter(function(n) { return n.has_probe; });
|
|
||||||
var covPct = (withProbe.length / c.networks.length * 100).toFixed(0);
|
|
||||||
var badgeClass = covPct >= 70 ? 'badge-green' : covPct >= 30 ? 'badge-orange' : 'badge-red';
|
|
||||||
var secId = 'country_' + cc;
|
|
||||||
|
|
||||||
h += '<div class="country-section">';
|
|
||||||
h += '<div class="country-header" onclick="var el=document.getElementById(\'' + secId + '\');el.classList.toggle(\'hidden\');this.querySelector(\'.arrow\').textContent=el.classList.contains(\'hidden\')?\'\u25B6\':\'\u25BC\'">';
|
|
||||||
h += '<span class="country-flag">' + flag(cc) + '</span>';
|
|
||||||
h += '<span class="country-name">' + escHtml(c.name) + ' (' + cc + ')</span>';
|
|
||||||
h += '<span class="country-count">' + noProbe.length + ' without probe / ' + c.networks.length + ' total</span>';
|
|
||||||
h += '<span class="' + badgeClass + ' country-badge">' + covPct + '% coverage</span>';
|
|
||||||
h += '<span class="arrow" style="color:var(--muted)">\u25B6</span>';
|
|
||||||
h += '</div>';
|
|
||||||
|
|
||||||
h += '<div id="' + secId + '" class="hidden">';
|
|
||||||
// Show networks WITHOUT probes first
|
|
||||||
if (noProbe.length > 0) {
|
|
||||||
h += '<div style="font-size:.7rem;color:var(--red);font-weight:600;margin:.5rem 0 .3rem;padding-left:.5rem">NO PROBE (' + noProbe.length + ')</div>';
|
|
||||||
h += '<div class="asn-grid">';
|
|
||||||
noProbe.sort(function(a,b) { return a.asn - b.asn; }).forEach(function(n) {
|
|
||||||
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + n.asn + '\',\'_blank\')">';
|
|
||||||
h += '<span class="asn-num">AS' + n.asn + '</span>';
|
|
||||||
h += '<span class="asn-name">' + escHtml(n.name || '') + '</span>';
|
|
||||||
if (n.info_type) h += '<span class="asn-type">' + escHtml(n.info_type) + '</span>';
|
|
||||||
h += '<span class="no-probe">\u2718 No Probe</span>';
|
|
||||||
h += '</div>';
|
|
||||||
});
|
|
||||||
h += '</div>';
|
|
||||||
}
|
|
||||||
// Networks WITH probes
|
|
||||||
if (withProbe.length > 0) {
|
|
||||||
h += '<div style="font-size:.7rem;color:var(--green);font-weight:600;margin:.75rem 0 .3rem;padding-left:.5rem">HAS PROBE (' + withProbe.length + ')</div>';
|
|
||||||
h += '<div class="asn-grid">';
|
|
||||||
withProbe.sort(function(a,b) { return a.asn - b.asn; }).slice(0, 20).forEach(function(n) {
|
|
||||||
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + n.asn + '\',\'_blank\')">';
|
|
||||||
h += '<span class="asn-num">AS' + n.asn + '</span>';
|
|
||||||
h += '<span class="asn-name">' + escHtml(n.name || '') + '</span>';
|
|
||||||
if (n.info_type) h += '<span class="asn-type">' + escHtml(n.info_type) + '</span>';
|
|
||||||
h += '<span class="has-probe">\u2714 Probe</span>';
|
|
||||||
h += '</div>';
|
|
||||||
});
|
|
||||||
if (withProbe.length > 20) h += '<div style="font-size:.75rem;color:var(--dim);padding:.3rem .5rem">+ ' + (withProbe.length - 20) + ' more with probes</div>';
|
|
||||||
h += '</div>';
|
|
||||||
}
|
|
||||||
h += '</div>';
|
|
||||||
h += '</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (countries.length === 0) {
|
|
||||||
h = '<div class="loading">No results found. Try a different search or RIR filter.</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$('mainContent').innerHTML = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchRIR(rir, btn) {
|
|
||||||
currentRIR = rir;
|
|
||||||
document.querySelectorAll('.rir-tab').forEach(function(t) { t.classList.remove('active'); });
|
|
||||||
btn.classList.add('active');
|
|
||||||
renderStats();
|
|
||||||
renderCountries();
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterResults() {
|
|
||||||
renderStats();
|
|
||||||
renderCountries();
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportPDF() {
|
|
||||||
// Generate a printable version
|
|
||||||
var w = window.open('', '_blank');
|
|
||||||
var filtered = getFiltered().filter(function(n) { return !n.has_probe; });
|
|
||||||
var byCountry = {};
|
|
||||||
filtered.forEach(function(n) {
|
|
||||||
var cc = n.country || 'XX';
|
|
||||||
if (!byCountry[cc]) byCountry[cc] = { name: n.country_name || cc, networks: [] };
|
|
||||||
byCountry[cc].networks.push(n);
|
|
||||||
});
|
|
||||||
|
|
||||||
var html = '<!DOCTYPE html><html><head><title>Atlas Coverage Report — Lia\'s Paradise</title>';
|
|
||||||
html += '<style>body{font-family:Arial,sans-serif;margin:2rem;color:#1a1a2e}h1{color:#5b21b6}h2{color:#7c3aed;margin-top:1.5rem;border-bottom:2px solid #e2e8f0;padding-bottom:.3rem}table{width:100%;border-collapse:collapse;margin:.5rem 0 1rem}th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #e2e8f0;font-size:.85rem}th{background:#f8fafc;font-weight:600}.no-probe{color:#dc2626;font-weight:600}.meta{color:#64748b;font-size:.8rem;margin-bottom:2rem}</style></head><body>';
|
|
||||||
html += '<h1>RIPE Atlas Coverage Report</h1>';
|
|
||||||
html += '<div class="meta">Generated by Lia\'s Paradise (peercortex.org/lia) on ' + new Date().toISOString().split('T')[0] + '<br>';
|
|
||||||
html += 'Filter: ' + (currentRIR === 'all' ? 'All RIRs' : currentRIR.toUpperCase()) + ' | Networks without Atlas Probe: ' + filtered.length + '</div>';
|
|
||||||
|
|
||||||
Object.keys(byCountry).sort(function(a,b) { return byCountry[b].networks.length - byCountry[a].networks.length; }).forEach(function(cc) {
|
|
||||||
var c = byCountry[cc];
|
|
||||||
html += '<h2>' + c.name + ' (' + cc + ') — ' + c.networks.length + ' networks</h2>';
|
|
||||||
html += '<table><thead><tr><th>ASN</th><th>Name</th><th>Type</th><th>Status</th></tr></thead><tbody>';
|
|
||||||
c.networks.sort(function(a,b) { return a.asn - b.asn; }).forEach(function(n) {
|
|
||||||
html += '<tr><td>AS' + n.asn + '</td><td>' + (n.name || '') + '</td><td>' + (n.info_type || '-') + '</td><td class="no-probe">No Probe</td></tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '<div class="meta" style="margin-top:3rem">Data from RIPE Atlas & PeeringDB | peercortex.org</div></body></html>';
|
|
||||||
w.document.write(html);
|
|
||||||
w.document.close();
|
|
||||||
w.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// File Upload: Parse ASNs/company names from uploaded files
|
|
||||||
// ============================================================
|
|
||||||
function handleFileUpload(input) {
|
|
||||||
var file = input.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
var status = $('fileStatus');
|
|
||||||
status.textContent = 'Processing ' + file.name + '...';
|
|
||||||
status.style.color = 'var(--cyan)';
|
|
||||||
|
|
||||||
var ext = file.name.split('.').pop().toLowerCase();
|
|
||||||
|
|
||||||
if (ext === 'csv' || ext === 'txt') {
|
|
||||||
file.text().then(function(text) { processFileText(text, file.name); });
|
|
||||||
} else if (ext === 'pdf' || ext === 'doc' || ext === 'docx' || ext === 'xls' || ext === 'xlsx') {
|
|
||||||
// For binary formats, upload to server for parsing
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
var base64 = e.target.result.split(',')[1];
|
|
||||||
fetch('/api/lia/parse-file', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ filename: file.name, data: base64 })
|
|
||||||
}).then(function(r) { return r.json(); }).then(function(d) {
|
|
||||||
if (d.error) {
|
|
||||||
status.textContent = 'Error: ' + d.error;
|
|
||||||
status.style.color = 'var(--red)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
processFileText(d.text || '', file.name);
|
|
||||||
}).catch(function(err) {
|
|
||||||
// Fallback: try reading as text
|
|
||||||
file.text().then(function(text) { processFileText(text, file.name); }).catch(function() {
|
|
||||||
status.textContent = 'Cannot parse ' + ext.toUpperCase() + ' files client-side. Use CSV or TXT.';
|
|
||||||
status.style.color = 'var(--red)';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} else {
|
|
||||||
status.textContent = 'Unsupported file type: ' + ext;
|
|
||||||
status.style.color = 'var(--red)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function processFileText(text, filename) {
|
|
||||||
var status = $('fileStatus');
|
|
||||||
|
|
||||||
// Extract ASNs (AS12345 or just numbers that look like ASNs)
|
|
||||||
var asnMatches = text.match(/\bAS?(\d{3,7})\b/gi) || [];
|
|
||||||
var asns = new Set();
|
|
||||||
asnMatches.forEach(function(m) {
|
|
||||||
var num = parseInt(m.replace(/^AS/i, ''));
|
|
||||||
if (num >= 100 && num <= 9999999) asns.add(num);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also try to match company names against our loaded data
|
|
||||||
var nameMatches = [];
|
|
||||||
if (allData.length > 0) {
|
|
||||||
var lines = text.split(/[\n\r,;]+/).map(function(l) { return l.trim().toLowerCase(); }).filter(function(l) { return l.length > 2; });
|
|
||||||
lines.forEach(function(line) {
|
|
||||||
allData.forEach(function(n) {
|
|
||||||
if (n.name && n.name.toLowerCase().indexOf(line) >= 0 && line.length > 4) {
|
|
||||||
asns.add(n.asn);
|
|
||||||
}
|
|
||||||
if (line.indexOf(n.name ? n.name.toLowerCase() : '###') >= 0 && n.name && n.name.length > 4) {
|
|
||||||
asns.add(n.asn);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asns.size === 0) {
|
|
||||||
status.textContent = 'No ASNs or matching networks found in ' + filename;
|
|
||||||
status.style.color = 'var(--orange)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match against our data
|
|
||||||
var results = [];
|
|
||||||
var asnArr = Array.from(asns);
|
|
||||||
var dataMap = {};
|
|
||||||
allData.forEach(function(n) { dataMap[n.asn] = n; });
|
|
||||||
|
|
||||||
asnArr.forEach(function(asn) {
|
|
||||||
var n = dataMap[asn];
|
|
||||||
results.push({
|
|
||||||
asn: asn,
|
|
||||||
name: n ? n.name : '(Unknown)',
|
|
||||||
country: n ? n.country : '',
|
|
||||||
info_type: n ? n.info_type : '',
|
|
||||||
has_probe: n ? n.has_probe : false,
|
|
||||||
in_peeringdb: !!n,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
results.sort(function(a, b) { return (a.has_probe ? 1 : 0) - (b.has_probe ? 1 : 0); });
|
|
||||||
|
|
||||||
var withProbe = results.filter(function(r) { return r.has_probe; }).length;
|
|
||||||
var noProbe = results.length - withProbe;
|
|
||||||
|
|
||||||
status.innerHTML = '<span style="color:var(--green)">' + withProbe + ' with probe</span> · <span style="color:var(--red)">' + noProbe + ' without probe</span> · ' + results.length + ' total from ' + escHtml(filename);
|
|
||||||
|
|
||||||
var h = '<div style="display:flex;gap:.5rem;margin-bottom:.75rem;flex-wrap:wrap">';
|
|
||||||
h += '<button class="export-btn" onclick="exportFileResults()" style="font-size:.7rem;padding:.3rem .8rem">Export Results as PDF</button>';
|
|
||||||
h += '</div>';
|
|
||||||
h += '<div class="asn-grid">';
|
|
||||||
results.forEach(function(r) {
|
|
||||||
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + r.asn + '\',\'_blank\')">';
|
|
||||||
h += '<span class="asn-num">AS' + r.asn + '</span>';
|
|
||||||
h += '<span class="asn-name">' + escHtml(r.name) + '</span>';
|
|
||||||
if (r.country) h += '<span style="font-size:.65rem;color:var(--dim)">' + flag(r.country) + ' ' + r.country + '</span>';
|
|
||||||
if (r.info_type) h += '<span class="asn-type">' + escHtml(r.info_type) + '</span>';
|
|
||||||
h += r.has_probe ? '<span class="has-probe">\u2714 Probe</span>' : '<span class="no-probe">\u2718 No Probe</span>';
|
|
||||||
if (!r.in_peeringdb) h += '<span style="font-size:.6rem;color:var(--dim)">(not in PeeringDB)</span>';
|
|
||||||
h += '</div>';
|
|
||||||
});
|
|
||||||
h += '</div>';
|
|
||||||
|
|
||||||
$('fileResults').innerHTML = h;
|
|
||||||
$('fileResults').classList.remove('hidden');
|
|
||||||
|
|
||||||
// Store for export
|
|
||||||
window._fileResults = results;
|
|
||||||
window._fileName = filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportFileResults() {
|
|
||||||
if (!window._fileResults) return;
|
|
||||||
var results = window._fileResults;
|
|
||||||
var w = window.open('', '_blank');
|
|
||||||
var html = '<!DOCTYPE html><html><head><title>Atlas Probe Check — ' + escHtml(window._fileName) + '</title>';
|
|
||||||
html += '<style>body{font-family:Arial,sans-serif;margin:2rem;color:#1a1a2e}h1{color:#5b21b6;font-size:1.3rem}table{width:100%;border-collapse:collapse;margin:1rem 0}th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #e2e8f0;font-size:.85rem}th{background:#f8fafc;font-weight:600}.yes{color:#16a34a;font-weight:700}.no{color:#dc2626;font-weight:700}.meta{color:#64748b;font-size:.8rem}</style></head><body>';
|
|
||||||
html += '<h1>Atlas Probe Coverage Check</h1>';
|
|
||||||
html += '<div class="meta">Source: ' + escHtml(window._fileName) + ' | Generated: ' + new Date().toISOString().split('T')[0] + ' | peercortex.org/lia</div>';
|
|
||||||
html += '<table><thead><tr><th>ASN</th><th>Name</th><th>Country</th><th>Type</th><th>Atlas Probe</th></tr></thead><tbody>';
|
|
||||||
results.forEach(function(r) {
|
|
||||||
html += '<tr><td>AS' + r.asn + '</td><td>' + escHtml(r.name) + '</td><td>' + (r.country || '-') + '</td><td>' + (r.info_type || '-') + '</td>';
|
|
||||||
html += '<td class="' + (r.has_probe ? 'yes' : 'no') + '">' + (r.has_probe ? 'YES' : 'NO') + '</td></tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table></body></html>';
|
|
||||||
w.document.write(html);
|
|
||||||
w.document.close();
|
|
||||||
w.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boot
|
|
||||||
loadData();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
2087
server.js.bak
2087
server.js.bak
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user