Compare commits

...

9 Commits

Author SHA1 Message Date
Rene Fichtmueller
344ee15338 feat(bio-rd): add local RIB integration via bio-rd gRPC client
Adds real-time local BGP RIB data as a complement to external APIs
(RIPE Stat, bgproutes.io) which have rate limits and 15min+ delays.

- bio-rd-client.js: gRPC client for bio-routing/bio-rd RIS service
  - LPM, Get, GetLonger, GetRouters, DumpRIB, ObserveRIB methods
  - IPv4/IPv6 encoding as uint64 pair (bio.net format)
  - Full BGP path decode: AS paths, communities, large communities
  - Graceful fallback if RIS unavailable (null/empty returns)
- protos/: bio-rd proto definitions (ris, bgp, session, route, net)
- server.js: three new endpoints + WebSocket stream
  - GET /api/rib/prefix — LPM + more-specifics via GetLonger
  - GET /api/rib/routers — list BMP-monitored routers
  - GET /api/rib/dump — full RIB dump with ASN filter + limit
  - WS /ws/rib — live ObserveRIB stream (add/withdraw events)
- package.json: @grpc/grpc-js + @grpc/proto-loader dependencies
2026-04-05 11:44:50 +02:00
Rene Fichtmueller
f1fe96132f fix: version strings all updated to v0.6.9 (masthead, footer, terminal) 2026-04-04 23:48:30 +02:00
Rene Fichtmueller
f6168f1329 feat: resilience score, route leak detection, data provenance, MCP server
- Resilience Score (1-10): weighted 4-factor model (transit diversity 30%,
  peering breadth 25%, IXP presence 20%, path redundancy 25%), hard cap at
  5.0 on single transit provider. Confidence: HIGH (cross-validated data).
- Route Leak Detection: heuristic Tier-1 sandwich/downstream pattern check.
  Confidence: MEDIUM — pattern-based, not real-time, false positives flagged.
- Data Provenance System: every API response field includes source, validation
  method and confidence level. UI shows green/orange provenance badges.
- MCP Server: exposes PeerCortex as Claude Desktop/Code tools (lookup_asn,
  compare_networks, get_health_report, search_network, get_resilience_score).
2026-04-04 23:46:36 +02:00
Rene Fichtmueller
a5335257a7 docs: add quality audit results + daily audit cron to v0.6.8 changelog 2026-04-03 01:57:50 +02:00
Rene Fichtmueller
9038e280fa fix: bgp.he.net name+country fallback for unregistered ASNs
For ASNs with no PeeringDB entry and no RIPE Stat holder (e.g. reserved
or unannounced ASNs), extract name from bgp.he.net page title and
country code from the /country/XX href. Eliminates the last 2 CRITICAL
audit failures (AS34465 → 'RIPE NCC ASN block'/GB, AS59947 → 'LLHOST
INC. SRL'/RO). Audit result: 80/82 PERFECT, 0 CRITICAL. v0.6.8.
2026-04-03 01:42:56 +02:00
Rene Fichtmueller
9012d2931f fix: RIR+Country empty (RIPE Stat .location field), RDAP parallel race (v0.6.7) 2026-04-02 23:08:54 +00:00
Rene Fichtmueller
9be247410c fix: IXP picker wrong data path + move Facilities card + IX capacity stat 2026-04-02 21:50:12 +00:00
Rene Fichtmueller
d417aa46c6 chore: gitignore runtime caches and large files 2026-04-02 21:40:35 +00:00
Rene Fichtmueller
32bb279c1d feat: add RS column, contacts, timing panel, JSON export, city (v0.6.6) 2026-04-02 21:39:28 +00:00
42 changed files with 76394 additions and 652 deletions

18
.gitignore vendored
View File

@ -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-*

View File

@ -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** (0100) 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** (0100) 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.

Binary file not shown.

1210
audit/asn_registry.json Normal file

File diff suppressed because it is too large Load Diff

56
audit/latest_report.txt Normal file
View 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

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 Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View 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
View 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;
}

View 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) {}
}

View 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;
}

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2357
public/index.html.bak Normal file

File diff suppressed because it is too large Load Diff

488
public/lia.html Normal file
View 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> &middot; Data from <a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a> &amp; <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 &amp; 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> &middot; <span style="color:var(--red)">' + noProbe + ' without probe</span> &middot; ' + 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>

1117
server.js

File diff suppressed because it is too large Load Diff

2087
server.js.bak Normal file

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