Compare commits
9 Commits
6fb0eb86af
...
344ee15338
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344ee15338 | ||
|
|
f1fe96132f | ||
|
|
f6168f1329 | ||
|
|
a5335257a7 | ||
|
|
9038e280fa | ||
|
|
9012d2931f | ||
|
|
9be247410c | ||
|
|
d417aa46c6 | ||
|
|
32bb279c1d |
18
.gitignore
vendored
18
.gitignore
vendored
@ -35,3 +35,21 @@ coverage/
|
||||
# TypeScript incremental
|
||||
*.tsbuildinfo
|
||||
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,80 +1,159 @@
|
||||
# Changelog
|
||||
# PeerCortex Changelog
|
||||
|
||||
All notable changes to PeerCortex are documented here.
|
||||
|
||||
## [0.5.0] — 2026-03-26
|
||||
---
|
||||
|
||||
## v0.6.6 — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **RPKI-based ASPA detection** via Cloudflare RPKI JSON feed — 1,455+ ASPA objects worldwide, cached and 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) with four dimensions: ROA coverage, ASPA object existence, provider match completeness, path validation rate
|
||||
- **Provider Audit** — compares RPKI-declared providers vs BGP-detected providers, highlights missing and extra entries with frequency data
|
||||
- **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
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **Raw JSON Export**: Added "⬇ Raw JSON" link in the network overview. Downloads the full lookup result as a formatted JSON file.
|
||||
- **HQ City in overview**: The network's registered city (from PeeringDB) now appears next to the country flag in the network overview header.
|
||||
|
||||
### 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
|
||||
- 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
|
||||
---
|
||||
|
||||
|
||||
## v0.6.4 — 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- **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
|
||||
- **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
|
||||
- **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.
|
||||
- **Service reliability**: Improved automatic recovery from unexpected process crashes — all services now restart automatically without manual intervention.
|
||||
|
||||
## [0.4.0] — 2026-03-25
|
||||
---
|
||||
|
||||
## v0.6.3 — 2026-04-02
|
||||
|
||||
### Added
|
||||
- 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
|
||||
- **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.
|
||||
|
||||
### Infrastructure
|
||||
- Node.js single-file server (server.js) — zero dependencies beyond Node.js built-ins
|
||||
- PM2 process management on Erik (217.154.82.179)
|
||||
- Cloudflare Tunnel via `eo-pulse` tunnel
|
||||
- Domains: peercortex.org, www.peercortex.org, peercortex.context-x.org
|
||||
---
|
||||
|
||||
## v0.6.2 — 2026-04-01
|
||||
|
||||
### Fixed
|
||||
- **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
|
||||
|
||||
| Source | API | Usage |
|
||||
|--------|-----|-------|
|
||||
| [PeeringDB](https://www.peeringdb.com/) | REST API v2 | Network profiles, IX connections, facilities |
|
||||
| [RIPE Stat](https://stat.ripe.net/) | Data API | Prefixes, neighbours, visibility, routing status, abuse contacts |
|
||||
| [RIPE Atlas](https://atlas.ripe.net/) | REST API v2 | Probe and anchor detection per ASN |
|
||||
| [Route Views](http://www.routeviews.org/) | Via RIPE Stat | BGP path data, AS relationships |
|
||||
| [bgp.he.net](https://bgp.he.net/) | HTML scraping | Supplementary BGP data |
|
||||
| [bgproutes.io](https://bgproutes.io/) | REST API v1 | 3,294+ vantage points, RIB data, ROV/ASPA status |
|
||||
| [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 |
|
||||
| Source | Usage |
|
||||
|--------|-------|
|
||||
| [PeeringDB](https://www.peeringdb.com/) | Network profiles, IX connections, facilities, peering policy |
|
||||
| [RIPE Stat](https://stat.ripe.net/) | Prefixes, neighbours, routing history, looking glass, abuse contacts |
|
||||
| [RIPE Atlas](https://atlas.ripe.net/) | Probe and anchor detection per ASN |
|
||||
| [bgproutes.io](https://bgproutes.io/) | Vantage point data, RIB queries, ROV/ASPA status |
|
||||
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation |
|
||||
| [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases |
|
||||
| [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion |
|
||||
|
||||
## v0.6.5 — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.
|
||||
|
||||
## [0.6.8] — 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- **Name fallback via bgp.he.net title**: ASNs without a PeeringDB entry and no RIPE Stat holder
|
||||
now extract their name from bgp.he.net page title (e.g. LLHOST INC. SRL, RIPE NCC ASN block)
|
||||
- **Country code fallback via bgp.he.net**: ASNs with no country in rir-stats-country
|
||||
now derive their 2-letter country code from bgp.he.net href (e.g. /country/RO, /country/GB)
|
||||
|
||||
### Quality Audit — 2026-04-03 (103 ASNs, dual-run validation)
|
||||
- **0 CRITICAL data errors** across all 103 audited ASNs
|
||||
- **97 PERFECT** — 94% with zero issues
|
||||
- **6 WARNING only** — slow cold-cache on large Tier-1 carriers and minor
|
||||
source disagreement in external registries (not PeerCortex data errors)
|
||||
- All mathematical consistency checks passed 103/103:
|
||||
prefix math · RPKI math · RPKI coverage% · IX dedup · facility counts
|
||||
- Prefix counts cross-validated against RIPE Stat: no deviation >10%
|
||||
- IX connections cross-validated against PeeringDB: no deviation >10%
|
||||
|
||||
### Infrastructure
|
||||
- Daily automated audit introduced: 103 ASNs validated every 24h
|
||||
|
||||
## [0.6.9] — 2026-04-04
|
||||
|
||||
### Added
|
||||
- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%),
|
||||
Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%).
|
||||
Hard cap at 5.0 when single transit provider detected.
|
||||
Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
|
||||
- **Route Leak Detection**: Heuristic pattern detection for suspicious routing
|
||||
relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM —
|
||||
pattern-based, not real-time. False positives possible.
|
||||
- **Data Provenance System**: Every data point in the API response now includes
|
||||
a _provenance field: source, validation method (cross-validated / heuristic /
|
||||
computed / single-source), and confidence level (high / medium / experimental).
|
||||
Visible in UI as colour-coded badges: green = validated, orange = indicative.
|
||||
- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude
|
||||
Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report,
|
||||
search_network, get_resilience_score. All responses include provenance metadata.
|
||||
|
||||
BIN
audit/__pycache__/audit.cpython-312.pyc
Normal file
BIN
audit/__pycache__/audit.cpython-312.pyc
Normal file
Binary file not shown.
1210
audit/asn_registry.json
Normal file
1210
audit/asn_registry.json
Normal file
File diff suppressed because it is too large
Load Diff
56
audit/latest_report.txt
Normal file
56
audit/latest_report.txt
Normal file
@ -0,0 +1,56 @@
|
||||
|
||||
============================================================
|
||||
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
|
||||
2270
audit/reports/2026-03-28.json
Normal file
2270
audit/reports/2026-03-28.json
Normal file
File diff suppressed because it is too large
Load Diff
2044
audit/reports/2026-03-30.json
Normal file
2044
audit/reports/2026-03-30.json
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_115951
Normal file
2693
backups/index.html.20260327_115951
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_121113
Normal file
2693
backups/index.html.20260327_121113
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_121443
Normal file
2693
backups/index.html.20260327_121443
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_124301
Normal file
2693
backups/index.html.20260327_124301
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_124716
Normal file
2693
backups/index.html.20260327_124716
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_131710
Normal file
2693
backups/index.html.20260327_131710
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_131812
Normal file
2693
backups/index.html.20260327_131812
Normal file
File diff suppressed because it is too large
Load Diff
2772
backups/index.html.20260327_133135
Normal file
2772
backups/index.html.20260327_133135
Normal file
File diff suppressed because it is too large
Load Diff
3024
backups/server.js.20260327_115951
Normal file
3024
backups/server.js.20260327_115951
Normal file
File diff suppressed because it is too large
Load Diff
3029
backups/server.js.20260327_121113
Normal file
3029
backups/server.js.20260327_121113
Normal file
File diff suppressed because it is too large
Load Diff
3028
backups/server.js.20260327_121443
Normal file
3028
backups/server.js.20260327_121443
Normal file
File diff suppressed because it is too large
Load Diff
3059
backups/server.js.20260327_124301
Normal file
3059
backups/server.js.20260327_124301
Normal file
File diff suppressed because it is too large
Load Diff
3069
backups/server.js.20260327_124716
Normal file
3069
backups/server.js.20260327_124716
Normal file
File diff suppressed because it is too large
Load Diff
3070
backups/server.js.20260327_131710
Normal file
3070
backups/server.js.20260327_131710
Normal file
File diff suppressed because it is too large
Load Diff
3071
backups/server.js.20260327_131812
Normal file
3071
backups/server.js.20260327_131812
Normal file
File diff suppressed because it is too large
Load Diff
3074
backups/server.js.20260327_133135
Normal file
3074
backups/server.js.20260327_133135
Normal file
File diff suppressed because it is too large
Load Diff
448
bio-rd-client.js
Normal file
448
bio-rd-client.js
Normal file
@ -0,0 +1,448 @@
|
||||
'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
Executable file
12
deploy.sh
Executable file
@ -0,0 +1,12 @@
|
||||
#!/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
Normal file
265
mcp-server.js
Normal file
@ -0,0 +1,265 @@
|
||||
#!/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
Normal file
6320
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "peercortex",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.5",
|
||||
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
||||
"main": "dist/mcp-server/index.js",
|
||||
"types": "dist/mcp-server/index.d.ts",
|
||||
@ -63,6 +63,8 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cheerio": "^1.0.0",
|
||||
@ -73,7 +75,6 @@
|
||||
"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",
|
||||
|
||||
104
protos/cmd/ris/api/ris.proto
Normal file
104
protos/cmd/ris/api/ris.proto
Normal file
@ -0,0 +1,104 @@
|
||||
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;
|
||||
}
|
||||
19
protos/net/api/net.proto
Normal file
19
protos/net/api/net.proto
Normal file
@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
34
protos/protocols/bgp/api/bgp.proto
Normal file
34
protos/protocols/bgp/api/bgp.proto
Normal file
@ -0,0 +1,34 @@
|
||||
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) {}
|
||||
}
|
||||
35
protos/protocols/bgp/api/session.proto
Normal file
35
protos/protocols/bgp/api/session.proto
Normal file
@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
80
protos/route/api/route.proto
Normal file
80
protos/route/api/route.proto
Normal file
@ -0,0 +1,80 @@
|
||||
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;
|
||||
}
|
||||
2898
public/index-classic.html
Normal file
2898
public/index-classic.html
Normal file
File diff suppressed because it is too large
Load Diff
2096
public/index.html
2096
public/index.html
File diff suppressed because it is too large
Load Diff
2357
public/index.html.bak
Normal file
2357
public/index.html.bak
Normal file
File diff suppressed because it is too large
Load Diff
488
public/lia.html
Normal file
488
public/lia.html
Normal file
@ -0,0 +1,488 @@
|
||||
<!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
Normal file
2087
server.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
2238
server.js.bak.20260327-003257
Normal file
2238
server.js.bak.20260327-003257
Normal file
File diff suppressed because it is too large
Load Diff
2087
server.js.bak.lia-1774526738149
Normal file
2087
server.js.bak.lia-1774526738149
Normal file
File diff suppressed because it is too large
Load Diff
2238
server.js.pre-lia-1774526865356
Normal file
2238
server.js.pre-lia-1774526865356
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user