feat: add RS column, contacts, timing panel, JSON export, city (v0.6.6)

This commit is contained in:
Rene Fichtmueller 2026-04-02 21:39:28 +00:00
parent 6fb0eb86af
commit 32bb279c1d
41 changed files with 74741 additions and 649 deletions

1
.pdb-org-cache.json Normal file

File diff suppressed because one or more lines are too long

1
.pdb-source-cache.json Normal file

File diff suppressed because one or more lines are too long

1
.ripe-stat-cache.json Normal file

File diff suppressed because one or more lines are too long

1
.roa-cache.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,80 +1,120 @@
# Changelog # PeerCortex Changelog
All notable changes to PeerCortex are documented here. All notable changes to PeerCortex are documented here.
## [0.5.0] — 2026-03-26 ---
## v0.6.6 — 2026-04-02
### Added ### Added
- **RPKI-based ASPA detection** via Cloudflare RPKI JSON feed — 1,455+ ASPA objects worldwide, cached and refreshed every 10 minutes - **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.
- **RFC-compliant ASPA path verification** (draft-ietf-sidrops-aspa-verification-14) — upstream/downstream verification, valley detection, AS_SET flagging, per-hop status - **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.
- **ASPA Readiness Score** (0100) with four dimensions: ROA coverage, ASPA object existence, provider match completeness, path validation rate - **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).
- **Provider Audit** — compares RPKI-declared providers vs BGP-detected providers, highlights missing and extra entries with frequency data - **Raw JSON Export**: Added "⬇ Raw JSON" link in the network overview. Downloads the full lookup result as a formatted JSON file.
- **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 - **HQ City in overview**: The network's registered city (from PeeringDB) now appears next to the country flag in the network overview header.
- **RIPE Atlas probe integration** — shows total probes, connected/disconnected counts, and anchors per ASN
- **Route Views** as data source and header navigation link
- **bgproutes.io integration** — 3,294+ vantage points, RIB queries, ROV and ASPA status
- **RPKI-Declared Providers section** — green badges showing providers from the actual RPKI ASPA object
- **Collapsible lists** — "Show X more..." for Detected Upstream Providers (limit 10), Missing/Extra in Provider Audit (limit 5)
- **Numerical ASN sorting** across all badge lists and tables
- **WHOIS Details** endpoint and dashboard card
- **Network Topology** endpoint via CAIDA AS-Relationships
- **Peering Partner Finder**`/api/peers/find` endpoint
- **Prefix Detail View**`/api/prefix/detail` endpoint
- **IX Detail View**`/api/ix/detail` endpoint
- **Recent Lookups** with localStorage persistence and quick-click badges
- **Network Compare** — side-by-side comparison of two ASNs (common IXPs, shared upstreams, overlapping facilities)
- **Copy button** on Recommended ASPA Object code block
- **Demo animation** (SVG) in README
### Changed ---
- ASPA detection switched from broken RIPE DB `aut-num` remarks search to Cloudflare RPKI JSON feed (`rpki.cloudflare.com/rpki.json`)
- Upstream providers now resolved with AS names via RIPE Stat AS Overview API
- Version bumped to 0.5.0 ## v0.6.4 — 2026-04-02
- Dashboard footer updated with all data sources including Cloudflare RPKI and Route Views
- Server User-Agent updated to PeerCortex/0.5.0
### Fixed ### 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 - **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.
- **SyntaxError in frontend** — CSS routing styles were embedded as a multiline JS string (single quotes don't allow newlines), moved to proper `<style>` block - **Service reliability**: Improved automatic recovery from unexpected process crashes — all services now restart automatically without manual intervention.
- **Double ASN display** — provider badges showed "AS1031 AS1031" when AS name wasn't available, now shows clean single ASN with resolved name
- **Empty brackets in ASPA template** — provider names showed "()" when not resolved, now omitted or fetched via RIPE Stat
- **Port conflict on startup** — multiple PM2 instances caused EADDRINUSE, resolved with proper process cleanup
- **RPKI per-prefix timeout** — limited batch size to 10 prefixes with 8s fetch timeout to prevent hanging on large ASNs
## [0.4.0] — 2026-03-25 ---
## v0.6.3 — 2026-04-02
### Added ### Added
- Initial public release - **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.
- Web dashboard with Tokyo Night dark theme
- PeeringDB API v2 integration (network profile, IX presence, facilities)
- RIPE Stat Data API integration (prefixes, neighbours, visibility, routing status)
- bgp.he.net scraping for supplementary BGP data
- Per-prefix RPKI validation via RIPE Stat
- AS neighbour resolution with names
- IPv4/IPv6 route propagation bars with RIS peer visibility
- Prefix size distribution badges
- MCP Server skeleton with 34 tool definitions
- Docker support
- Cloudflare Tunnel deployment on Erik server
- Live demo at peercortex.org
### Infrastructure ---
- Node.js single-file server (server.js) — zero dependencies beyond Node.js built-ins
- PM2 process management on Erik (217.154.82.179) ## v0.6.2 — 2026-04-01
- Cloudflare Tunnel via `eo-pulse` tunnel
- Domains: peercortex.org, www.peercortex.org, peercortex.context-x.org ### 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 ## Data Sources
| Source | API | Usage | | Source | Usage |
|--------|-----|-------| |--------|-------|
| [PeeringDB](https://www.peeringdb.com/) | REST API v2 | Network profiles, IX connections, facilities | | [PeeringDB](https://www.peeringdb.com/) | Network profiles, IX connections, facilities, peering policy |
| [RIPE Stat](https://stat.ripe.net/) | Data API | Prefixes, neighbours, visibility, routing status, abuse contacts | | [RIPE Stat](https://stat.ripe.net/) | Prefixes, neighbours, routing history, looking glass, abuse contacts |
| [RIPE Atlas](https://atlas.ripe.net/) | REST API v2 | Probe and anchor detection per ASN | | [RIPE Atlas](https://atlas.ripe.net/) | Probe and anchor detection per ASN |
| [Route Views](http://www.routeviews.org/) | Via RIPE Stat | BGP path data, AS relationships | | [bgproutes.io](https://bgproutes.io/) | Vantage point data, RIB queries, ROV/ASPA status |
| [bgp.he.net](https://bgp.he.net/) | HTML scraping | Supplementary BGP data | | [Cloudflare RPKI](https://rpki.cloudflare.com/) | ASPA objects, ROA validation |
| [bgproutes.io](https://bgproutes.io/) | REST API v1 | 3,294+ vantage points, RIB data, ROV/ASPA status | | [NLNOG IRR Explorer](https://irrexplorer.nlnog.net/) | IRR registration across all major databases |
| [Cloudflare RPKI](https://rpki.cloudflare.com/) | JSON feed | 1,455+ ASPA objects, ROA validation | | [RIPE DB](https://rest.db.ripe.net/) | WHOIS data, IRR objects, AS-SET expansion |
| [RIPE DB](https://rest.db.ripe.net/) | REST API | IRR objects, WHOIS data |
## v0.6.5 — 2026-04-02
### Added
- **Name search with autocomplete**: Type any network or organization name in the search bar to get live suggestions. Results are sourced from both RIPE Stat and PeeringDB — covering thousands of registered networks worldwide. Use arrow keys to navigate, Enter or click to select.

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

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

72
feedback.json Normal file
View File

@ -0,0 +1,72 @@
[
{
"id": "1774898220422-rvnhx",
"timestamp": "2026-03-30T19:17:00.422Z",
"category": "General",
"message": "super system",
"name": "rene",
"asn": "199121",
"ip": "2001:9e8:f1b:9f00:d887:294:313b:9c36",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
},
{
"id": "1775040555744-s9j9e",
"timestamp": "2026-04-01T10:49:15.744Z",
"category": "Bug Report",
"message": "Link to Github is 404",
"name": "boggits",
"asn": null,
"ip": "2a02:c7e:27f0:ed00:b1cf:6df7:778b:e39e",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
},
{
"id": "1775040599910-boa1u",
"timestamp": "2026-04-01T10:49:59.910Z",
"category": "Design Feedback",
"message": "Yesterdays design was much nicer",
"name": "boggits",
"asn": "8468",
"ip": "2a02:c7e:27f0:ed00:b1cf:6df7:778b:e39e",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
},
{
"id": "1775071498890-9kgqc",
"timestamp": "2026-04-01T19:24:58.890Z",
"category": "Bug Report",
"message": "what is this?",
"name": "Anonymous",
"asn": null,
"ip": "178.150.155.126",
"ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
},
{
"id": "1775071544103-n2of1",
"timestamp": "2026-04-01T19:25:44.104Z",
"category": "General",
"message": "dsfsdf",
"name": "sfsdf",
"asn": null,
"ip": "178.150.155.126",
"ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
},
{
"id": "1775075188335-683gm",
"timestamp": "2026-04-01T20:26:28.335Z",
"category": "General",
"message": "alles cool dies ist ein test",
"name": "Anonymous",
"asn": "199121",
"ip": "2001:9e8:f39:a200:593b:fcc4:2cb1:6678",
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
},
{
"id": "1775076357112-yzxzf",
"timestamp": "2026-04-01T20:45:57.112Z",
"category": "Bug Report",
"message": "Test email from PeerCortex setup - please ignore",
"name": "Rene Test",
"asn": "12345",
"ip": "127.0.0.1",
"ua": ""
}
]

11
hijack-subs.json Normal file
View File

@ -0,0 +1,11 @@
[
{
"asn": "199121",
"email": "",
"prefixes": [
"2001:67c:7a4::/48",
"91.244.180.0/24"
],
"subscribed": "2026-04-01T21:04:03.235Z"
}
]

6007
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "peercortex", "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.", "description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
"main": "dist/mcp-server/index.js", "main": "dist/mcp-server/index.js",
"types": "dist/mcp-server/index.d.ts", "types": "dist/mcp-server/index.d.ts",

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>

765
server.js
View File

@ -1,6 +1,7 @@
const fs = require("fs"); const fs = require("fs");
const http = require("http"); const http = require("http");
const https = require("https"); const https = require("https");
const crypto = require("crypto");
// Load .env file // Load .env file
const envPath = "/opt/peercortex-app/.env"; const envPath = "/opt/peercortex-app/.env";
@ -192,6 +193,205 @@ function queryPeeringDBLocal(path) {
const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env"; const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json"; const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
const VISITORS_FILE = "/opt/peercortex-app/visitors.json";
// ── SMTP / Email ──────────────────────────────────────────────
const SMTP_HOST = 'mail.fichtmueller.org';
const SMTP_PORT = 587;
const SMTP_USER = process.env.SMTP_USER;
const SMTP_PASS = process.env.SMTP_PASS;
const MAIL_TO = 'peercortex@context-x.org';
const MAIL_FROM = 'PeerCortex Feedback <rene@fichtmueller.org>';
function sendFeedbackMail(entry) {
return new Promise(function(resolve, reject) {
var tls = require('tls');
var net = require('net');
var b64 = function(s) { return Buffer.from(s).toString('base64'); };
var CRLF = '\r\n';
var body = 'Category : ' + entry.category + CRLF +
'Name : ' + entry.name + CRLF +
'ASN : ' + (entry.asn || '-') + CRLF +
'Time : ' + entry.timestamp + CRLF + CRLF +
entry.message + CRLF + CRLF + '-' + CRLF + 'PeerCortex Feedback';
var subj = '[PeerCortex Feedback] ' + entry.category + (entry.asn ? ' - AS' + entry.asn : '');
var msg = 'From: ' + MAIL_FROM + CRLF +
'To: ' + MAIL_TO + CRLF +
'Subject: ' + subj + CRLF +
'MIME-Version: 1.0' + CRLF +
'Content-Type: text/plain; charset=UTF-8' + CRLF + CRLF +
body;
var socket = net.connect(SMTP_PORT, SMTP_HOST);
var tlsSocket = null;
var buf = '';
var step = 0;
var done = false;
function send(line) {
var s = tlsSocket || socket;
s.write(line + CRLF);
}
function onData(data) {
buf += data.toString();
var lines = buf.split(CRLF);
buf = lines.pop();
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var code = parseInt(line.slice(0, 3));
if (isNaN(code) || line[3] === '-') continue;
if (step === 0 && code === 220) { send('EHLO peercortex.org'); step = 1; }
else if (step === 1 && code === 250) { send('STARTTLS'); step = 2; }
else if (step === 2 && code === 220) {
tlsSocket = tls.connect({ socket: socket, servername: SMTP_HOST, rejectUnauthorized: false }, function() {
tlsSocket.on('data', onData);
send('EHLO peercortex.org');
step = 3;
});
tlsSocket.on('error', function(e) { if (!done) { done = true; reject(e); } });
}
else if (step === 3 && code === 250) { send('AUTH LOGIN'); step = 4; }
else if (step === 4 && code === 334) { send(b64(SMTP_USER)); step = 5; }
else if (step === 5 && code === 334) { send(b64(SMTP_PASS)); step = 6; }
else if (step === 6 && code === 235) { send('MAIL FROM:<' + SMTP_USER + '>'); step = 7; }
else if (step === 7 && code === 250) { send('RCPT TO:<' + MAIL_TO + '>'); step = 8; }
else if (step === 8 && code === 250) { send('DATA'); step = 9; }
else if (step === 9 && code === 354) { send(msg + CRLF + '.'); step = 10; }
else if (step === 10 && code === 250) { send('QUIT'); if (!done) { done = true; resolve(); } }
else if (code >= 400) { if (!done) { done = true; reject(new Error('SMTP ' + code + ': ' + line)); } }
}
}
socket.on('data', onData);
socket.on('error', function(e) { if (!done) { done = true; reject(e); } });
setTimeout(function() { if (!done) { done = true; reject(new Error('SMTP timeout')); } }, 15000);
});
}
// ── SMTP / Email ──────────────────────────────────────────────
function loadVisitors() {
try { return JSON.parse(fs.readFileSync(VISITORS_FILE, "utf8")); } catch (_) { return { hashes: [] }; }
}
function trackVisitor(req) {
const ip = (req.headers["x-forwarded-for"] || "").split(",")[0].trim() || (req.socket && req.socket.remoteAddress) || "";
const hash = crypto.createHash("sha256").update(ip + "peercortex-salt-2026").digest("hex");
const data = loadVisitors();
if (!data.hashes.includes(hash)) {
data.hashes.push(hash);
try { fs.writeFileSync(VISITORS_FILE, JSON.stringify(data)); } catch (_) {}
}
return data.hashes.length;
}
// ═══════════════════════════════════════════════════════════════
// PEERCORTEX v0.6.1 — New Features
// ═══════════════════════════════════════════════════════════════
// ── BGP Community Database ─────────────────────────────────────
const BGP_COMMUNITY_DB = {
'65535:666': { name:'BLACKHOLE', desc:'RFC 7999 — Null-route this prefix', type:'rfc' },
'65535:65281':{ name:'NO_EXPORT', desc:'RFC 1997 — Do not export to EBGP peers', type:'rfc' },
'65535:65282':{ name:'NO_ADVERTISE', desc:'RFC 1997 — Do not advertise to any peer', type:'rfc' },
'65535:65283':{ name:'NO_EXPORT_SUBCONFED', desc:'RFC 1997 — No export to sub-AS', type:'rfc' },
// Lumen/CenturyLink 3356
'3356:2': { name:'Lumen Peer', desc:'Lumen — Learned from settlement-free peer', type:'carrier', asn:3356 },
'3356:3': { name:'Lumen Customer', desc:'Lumen — Learned from customer', type:'carrier', asn:3356 },
'3356:100':{ name:'Lumen Blackhole', desc:'Lumen — RTBH trigger', type:'carrier', asn:3356 },
// NTT 2914
'2914:420':{ name:'NTT Peer', desc:'NTT — Settlement-free peer route', type:'carrier', asn:2914 },
'2914:421':{ name:'NTT Customer', desc:'NTT — Downstream customer route', type:'carrier', asn:2914 },
'2914:666':{ name:'NTT Blackhole', desc:'NTT — RTBH trigger', type:'carrier', asn:2914 },
// Cogent 174
'174:21000':{ name:'Cogent Peer', desc:'Cogent — Learned from peer', type:'carrier', asn:174 },
'174:22000':{ name:'Cogent Customer', desc:'Cogent — Learned from customer', type:'carrier', asn:174 },
'174:666': { name:'Cogent Blackhole', desc:'Cogent — RTBH trigger', type:'carrier', asn:174 },
// HE 6939
'6939:7000':{ name:'HE RTBH', desc:'Hurricane Electric — Remotely triggered blackhole', type:'carrier', asn:6939 },
// Telia 1299
'1299:35000':{ name:'Telia RTBH', desc:'Telia — Remotely triggered blackhole', type:'carrier', asn:1299 },
'1299:3000': { name:'Telia Peer', desc:'Telia — Learned from peer', type:'carrier', asn:1299 },
// DTAG 3320
'3320:1278':{ name:'DTAG Peer', desc:'Deutsche Telekom — Peering route', type:'carrier', asn:3320 },
'3320:2001':{ name:'DTAG Customer', desc:'Deutsche Telekom — Customer route', type:'carrier', asn:3320 },
'3320:9900':{ name:'DTAG Blackhole', desc:'Deutsche Telekom — RTBH trigger', type:'carrier', asn:3320 },
// Cloudflare 13335
'13335:10000':{ name:'CF Customer', desc:'Cloudflare — Customer route', type:'carrier', asn:13335 },
'13335:10010':{ name:'CF Peering', desc:'Cloudflare — Learned via peering', type:'carrier', asn:13335 },
'13335:20050':{ name:'CF Blackhole', desc:'Cloudflare — RTBH trigger', type:'carrier', asn:13335 },
// Zayo 6461
'6461:9000':{ name:'Zayo Blackhole', desc:'Zayo — RTBH trigger', type:'carrier', asn:6461 },
// DE-CIX 6695
'6695:1000':{ name:'DE-CIX RS', desc:'DE-CIX Frankfurt — Route server export', type:'ixp', asn:6695 },
'6695:1001':{ name:'DE-CIX RS peer', desc:'DE-CIX — Received from route server peer', type:'ixp', asn:6695 },
// AMS-IX 1200
'1200:100': { name:'AMS-IX RS', desc:'AMS-IX — Route server export', type:'ixp', asn:1200 },
// LINX 5459
'5459:1001':{ name:'LINX RS', desc:'LINX — Route server export', type:'ixp', asn:5459 },
// Seabone/TI 6762
'6762:30': { name:'Seabone Customer', desc:'Telecom Italia Seabone — Customer route', type:'carrier', asn:6762 },
// Turkcell 9121
'9121:666': { name:'Turkcell BH', desc:'Turkcell — RTBH trigger', type:'carrier', asn:9121 },
};
function decodeCommunities(communityList) {
if (!Array.isArray(communityList)) return [];
return communityList.map(c => {
const key = Array.isArray(c) ? c.join(':') : String(c);
const known = BGP_COMMUNITY_DB[key];
return { raw: key, known: known || null };
});
}
// ── Hijack Monitoring ──────────────────────────────────────────
const HIJACK_SUBS_FILE = '/opt/peercortex-app/hijack-subs.json';
const HIJACK_ALERTS_FILE = '/opt/peercortex-app/hijack-alerts.json';
function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }
async function checkHijacksForAsn(asn) {
try {
const url = `https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}&${UA}`;
const data = await fetchJSONWithRetry(url, { timeout: 15000 });
const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
return prefixes;
} catch (_) { return []; }
}
async function runHijackCheck() {
const subs = loadHijackSubs();
if (!subs.length) return;
const alerts = loadHijackAlerts();
for (const sub of subs) {
const current = await checkHijacksForAsn(sub.asn);
const baseline = new Set(sub.prefixes || []);
const unexpected = current.filter(p => baseline.size > 0 && !baseline.has(p));
const missing = [...baseline].filter(p => !current.includes(p));
if (unexpected.length || missing.length) {
const alert = {
asn: sub.asn, ts: new Date().toISOString(),
unexpected, missing,
msg: `Possible hijack detected for AS${sub.asn}: ${unexpected.length} unexpected, ${missing.length} missing prefixes`
};
alerts.push(alert);
try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(alerts.slice(-500), null, 2)); } catch(_) {}
}
// Update baseline with current prefixes if no baseline set
if (!sub.prefixes || !sub.prefixes.length) {
sub.prefixes = current;
try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2)); } catch(_) {}
}
}
}
// Run hijack check every 30 minutes
setInterval(runHijackCheck, 30 * 60 * 1000);
const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)"; const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)";
@ -1583,8 +1783,12 @@ const server = http.createServer(async (req, res) => {
return res.end('shell.html not found'); return res.end('shell.html not found');
} }
} }
// v2.peercortex.org → editorial design // v2.peercortex.org → redirect to main domain
const htmlFile = (host === 'v2.peercortex.org') ? "index-editorial.html" : "index.html"; if (host === 'v2.peercortex.org') {
res.writeHead(301, { Location: 'https://peercortex.org' + reqPath });
return res.end();
}
const htmlFile = "index.html";
try { try {
const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8"); const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
@ -1611,6 +1815,85 @@ const server = http.createServer(async (req, res) => {
// Feedback API // Feedback API
// ============================================================ // ============================================================
// ── Name Search (RIPE Stat + PeeringDB combined) ─────────────
if (reqPath === '/api/search') {
const params = new URL(req.url, 'http://localhost').searchParams;
const q = (params.get('q') || '').trim();
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=120');
if (!q || q.length < 2) { res.writeHead(400); return res.end(JSON.stringify({error:'query too short'})); }
try {
const results = [];
const seen = new Set();
// Source 1: RIPE Stat searchcomplete (fast, covers ASNs + org names)
try {
const ripeUrl = 'https://stat.ripe.net/data/searchcomplete/data.json?resource=' + encodeURIComponent(q);
const ripeData = await fetchJSONWithRetry(ripeUrl, { timeout: 6000 });
const cats = ripeData && ripeData.data && ripeData.data.categories || [];
for (var ci = 0; ci < cats.length; ci++) {
var suggs = cats[ci].suggestions || [];
for (var si = 0; si < suggs.length; si++) {
var s = suggs[si];
var val = (s.value || '').toString();
// Only ASN results
if (/^AS\d+$/i.test(val) && !seen.has(val)) {
seen.add(val);
// Use description (e.g. "FLEXOPTIX, DE") as the display label
var ripeName = s.description || s.label || val;
results.push({ asn: val.replace(/^AS/i,''), label: ripeName, description: '', source: 'RIPE Stat' });
}
}
}
} catch(e) { /* RIPE Stat failed, continue */ }
// Source 2: PeeringDB name search (best for network operator names)
try {
var pdbUrl = 'https://www.peeringdb.com/api/net?name__icontains=' + encodeURIComponent(q) + '&depth=1&limit=10';
if (PEERINGDB_API_KEY) pdbUrl += '&key=' + PEERINGDB_API_KEY;
const pdbData = await fetchJSONWithRetry(pdbUrl, { timeout: 8000 });
var nets = pdbData && pdbData.data || [];
for (var ni = 0; ni < nets.length; ni++) {
var net = nets[ni];
var asnKey = 'AS' + net.asn;
if (net.asn && !seen.has(asnKey)) {
seen.add(asnKey);
var pdbDesc = [net.info_type, net.country].filter(Boolean).join(' · ');
results.push({
asn: String(net.asn),
label: net.name || asnKey,
description: pdbDesc,
source: 'PeeringDB'
});
}
}
} catch(e) { /* PeeringDB failed, continue */ }
// Sort: RIPE results first (usually more relevant for ASN lookup), then PeeringDB
results.sort((a, b) => {
if (a.source === b.source) return 0;
return a.source === 'RIPE Stat' ? -1 : 1;
});
res.writeHead(200);
return res.end(JSON.stringify({ q: q, results: results.slice(0, 12) }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error: e.message}));
}
}
// GET /api/visitors — unique visitor count
if (reqPath === "/api/visitors" && req.method === "GET") {
res.setHeader("Content-Type", "application/json");
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cache-Control", "no-store");
const count = trackVisitor(req);
res.writeHead(200);
return res.end(JSON.stringify({ visitors: count }));
}
// OPTIONS preflight (CORS) // OPTIONS preflight (CORS)
if (reqPath === '/api/feedback' && req.method === 'OPTIONS') { if (reqPath === '/api/feedback' && req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
@ -1647,6 +1930,8 @@ const server = http.createServer(async (req, res) => {
try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ } try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ }
entries.push(entry); entries.push(entry);
fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2)); fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2));
// Send email async — don't block response
sendFeedbackMail(entry).catch(e => console.error('[MAIL] Failed:', e.message));
return res.end(JSON.stringify({ ok: true, id: entry.id })); return res.end(JSON.stringify({ ok: true, id: entry.id }));
} catch (_e) { } catch (_e) {
res.writeHead(500); res.writeHead(500);
@ -1804,7 +2089,7 @@ const server = http.createServer(async (req, res) => {
JSON.stringify({ JSON.stringify({
status, status,
service: "PeerCortex", service: "PeerCortex",
version: "0.6.0", version: "0.6.6",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime_seconds: Math.floor(process.uptime()), uptime_seconds: Math.floor(process.uptime()),
memory_mb: Math.round(mem.heapUsed / 1024 / 1024), memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
@ -2843,19 +3128,45 @@ const server = http.createServer(async (req, res) => {
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey); let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null; let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
const promises = [ // Per-source timing tracking
fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 }), const sourceTiming = {};
fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 }), function timedFetch(name, promise) {
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), const ts = Date.now();
fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn), return Promise.resolve(promise)
fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"), .then(r => { sourceTiming[name] = Date.now() - ts; return r; })
fetchBgpHeNet(asn), .catch(() => { sourceTiming[name] = null; return null; });
fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 30000 }), }
fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn),
cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery), const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null)), const rdapForReg = [
"https://rdap.db.ripe.net/autnum/" + asn,
"https://rdap.apnic.net/autnum/" + asn,
"https://rdap.arin.net/registry/autnum/" + asn,
]; ];
const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData] = await Promise.all(promises);
const promises = [
timedFetch("RIPE Stat Prefixes", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 45000 })),
timedFetch("RIPE Stat Neighbours", fetchRipeStatCachedWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 45000 })),
timedFetch("RIPE Stat Overview", fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn)),
timedFetch("RIPE Stat RIR", fetchRipeStatCached("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn)),
timedFetch("RIPE Atlas", fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500")),
timedFetch("bgp.he.net", fetchBgpHeNet(asn)),
timedFetch("RIPE Stat Visibility", fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 30000 })),
timedFetch("RIPE Stat PrefixSize", fetchRipeStatCached("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn)),
timedFetch("PeeringDB IXLan", cachedIxlan ? Promise.resolve(cachedIxlan) : fetchPeeringDBWithRetry(ixQuery)),
timedFetch("PeeringDB Facilities", cachedFac ? Promise.resolve(cachedFac) : (netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null))),
timedFetch("PeeringDB Contacts", pocQuery ? fetchPeeringDB(pocQuery).catch(() => null) : Promise.resolve(null)),
timedFetch("RDAP Registration", (async () => {
for (const url of rdapForReg) {
try {
const d = await fetchJSON(url, { timeout: 5000 });
if (d && !d.errorCode && d.handle) return d;
} catch(e) {}
}
return null;
})()),
];
const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData, pocData, rdapData] = await Promise.all(promises);
// Store PDB results in L2 source cache for future lookups // Store PDB results in L2 source cache for future lookups
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData); if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
@ -2887,6 +3198,7 @@ const server = http.createServer(async (req, res) => {
ipv4: ix.ipaddr4 || null, ipv4: ix.ipaddr4 || null,
ipv6: ix.ipaddr6 || null, ipv6: ix.ipaddr6 || null,
city: ix.city || "", city: ix.city || "",
is_rs_peer: ix.is_rs_peer === true,
})) }))
.sort((a, b) => b.speed_mbps - a.speed_mbps); .sort((a, b) => b.speed_mbps - a.speed_mbps);
@ -3189,7 +3501,7 @@ const server = http.createServer(async (req, res) => {
const result = { const result = {
meta: { meta: {
service: "PeerCortex", service: "PeerCortex",
version: "0.5.0", version: "0.6.6",
query: "AS" + asn, query: "AS" + asn,
duration_ms: duration, duration_ms: duration,
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"], sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
@ -3212,8 +3524,16 @@ const server = http.createServer(async (req, res) => {
peeringdb_id: netId || null, peeringdb_id: netId || null,
rir: rir, rir: rir,
country: country, country: country,
city: net.city || "",
latitude: (net.latitude != null) ? net.latitude : null,
longitude: (net.longitude != null) ? net.longitude : null,
looking_glass: net.looking_glass || "", looking_glass: net.looking_glass || "",
route_server: net.route_server || "", route_server: net.route_server || "",
info_prefixes4: net.info_prefixes4 || 0,
info_prefixes6: net.info_prefixes6 || 0,
status: net.status || "",
peeringdb_created: net.created ? net.created.slice(0, 10) : "",
peeringdb_updated: net.updated ? net.updated.slice(0, 10) : "",
}, },
prefixes: { prefixes: {
total: prefixes.length, total: prefixes.length,
@ -3269,6 +3589,29 @@ const server = http.createServer(async (req, res) => {
})), })),
}, },
data_quality: dataQuality, data_quality: dataQuality,
source_timing: sourceTiming,
contacts: (() => {
const pocs = (pocData && pocData.data) ? pocData.data : [];
return pocs.slice(0, 20).map(p => ({
role: p.role || "",
name: p.name || "",
email: p.email || "",
url: p.url || "",
visible: p.visible || "",
}));
})(),
registration: (() => {
const events = (rdapData && rdapData.events) ? rdapData.events : [];
const created = (events.find(e => e.eventAction === "registration") || {}).eventDate || "";
const lastChg = (events.find(e => e.eventAction === "last changed") || {}).eventDate || "";
return {
created: created ? created.slice(0, 10) : "",
last_modified: lastChg ? lastChg.slice(0, 10) : "",
rir: rir || "",
handle: (rdapData && rdapData.handle) ? rdapData.handle : ("AS" + asn),
rdap_source: (rdapData && rdapData.port43) ? rdapData.port43 : "",
};
})(),
}; };
// Update duration to include cross-check time // Update duration to include cross-check time
@ -3949,6 +4292,394 @@ const server = http.createServer(async (req, res) => {
return res.end(result); return res.end(result);
} }
// ── Changelog page ─────────────────────────────────────────
if (reqPath === '/changelog') {
try {
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
const lines = md.split('\n');
let html = '';
for (const line of lines) {
if (line.startsWith('## ')) {
html += `<h2 style="font-family:var(--serif);font-size:1.4rem;font-weight:800;margin:2rem 0 .5rem;border-top:2px solid var(--text);padding-top:1rem">${line.slice(3)}</h2>`;
} else if (line.startsWith('### ')) {
html += `<h3 style="font-family:var(--body);font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:1rem 0 .4rem">${line.slice(4)}</h3>`;
} else if (line.startsWith('- **')) {
const m = line.replace(/^- \*\*(.+?)\*\*(.*)$/, '<strong>$1</strong>$2');
html += `<p style="font-family:var(--body);font-size:.85rem;margin:.2rem 0;padding-left:1rem;border-left:2px solid var(--border)">· ${m}</p>`;
} else if (line.startsWith('- ')) {
html += `<p style="font-family:var(--body);font-size:.82rem;margin:.15rem 0;color:var(--muted);padding-left:1rem">· ${line.slice(2)}</p>`;
} else if (line.startsWith('# ')) {
html += `<h1 style="font-family:var(--serif);font-size:2rem;font-weight:900;margin-bottom:.25rem">${line.slice(2)}</h1>`;
}
}
const page = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>PeerCortex Changelog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#F5F2EC;--text:#1C1917;--muted:#57534E;--border:#C9C3B6;--serif:'Playfair Display',Georgia,serif;--body:'Source Serif 4',Georgia,serif;--mono:'IBM Plex Mono',monospace;--purple:#B83A1B}
body{font-family:var(--body);background:var(--bg);color:var(--text);max-width:760px;margin:0 auto;padding:2rem}
a{color:var(--purple);text-decoration:none}
.back{font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-bottom:2rem;display:block}
body.dark{--bg:#0f0f0f;--text:#e8e4dc;--muted:#a09890;--border:#333}
</style></head><body>
<a href="/" class="back"> peercortex.org</a>
${html}
<p style="margin-top:3rem;font-family:var(--mono);font-size:.6rem;color:var(--muted)">PeerCortex · v0.5.0 · Open Source · MIT</p>
</body></html>`;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.writeHead(200);
return res.end(page);
} catch(e) {
res.writeHead(500); return res.end('Changelog not available');
}
}
// ── BGP Community Decoder ────────────────────────────────────
if (reqPath === '/api/communities') {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = params.get('asn') || '';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
try {
const url = `https://stat.ripe.net/data/bgp-state/data.json?resource=AS${asn.replace('AS','')}`;
const data = await fetchJSONWithRetry(url, { timeout: 12000 });
const rawComms = [];
if (data && data.data && data.data.bgp_state) {
for (const entry of data.data.bgp_state.slice(0, 50)) {
if (entry.community) rawComms.push(...entry.community);
}
}
const unique = [...new Set(rawComms.map(c => Array.isArray(c) ? c.join(':') : String(c)))];
const decoded = unique.map(k => ({ raw: k, known: BGP_COMMUNITY_DB[k] || null }));
res.writeHead(200);
return res.end(JSON.stringify({ asn, communities: decoded, db_size: Object.keys(BGP_COMMUNITY_DB).length }));
} catch(e) {
res.writeHead(200);
return res.end(JSON.stringify({ asn, communities: [], error: e.message }));
}
}
// ── IRR Audit ─────────────────────────────────────────────────
if (reqPath.startsWith('/api/irr-audit')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=1800');
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
// Use NLNOG IRR Explorer — covers RIPE, ARIN, APNIC, RPKI, and all major IRR databases
const nlnogData = await fetchJSONWithRetry(
'https://irrexplorer.nlnog.net/api/prefixes/asn/AS' + asn,
{ timeout: 20000 }
);
const prefixes = nlnogData && nlnogData.directOrigin || [];
var irrRoutes = [];
var irrDetails = [];
var goodCount = 0;
var warnCount = 0;
var errorCount = 0;
for (var i = 0; i < prefixes.length; i++) {
var pfx = prefixes[i];
var hasIrr = pfx.irrRoutes && Object.keys(pfx.irrRoutes).length > 0;
var sources = hasIrr ? Object.keys(pfx.irrRoutes) : [];
var cat = pfx.categoryOverall || 'unknown';
if (hasIrr) irrRoutes.push(pfx.prefix);
irrDetails.push({
prefix: pfx.prefix,
irr_sources: sources,
rpki_status: pfx.rpkiRoutes && pfx.rpkiRoutes.length ? pfx.rpkiRoutes[0].rpkiStatus : 'not-found',
category: cat,
messages: (pfx.messages || []).map(function(m){ return m.text; })
});
if (cat === 'success') goodCount++;
else if (cat === 'warning') warnCount++;
else errorCount++;
}
var actualPfx = prefixes.map(function(p){ return p.prefix; });
var inBgpNotIrr = actualPfx.filter(function(p){ return !irrRoutes.includes(p); });
var score = actualPfx.length ? Math.round(irrRoutes.length / actualPfx.length * 100) : 0;
res.writeHead(200);
return res.end(JSON.stringify({
asn: asn,
irr_routes: irrRoutes,
actual_prefixes: actualPfx,
in_irr_not_bgp: [],
in_bgp_not_irr: inBgpNotIrr,
score: score,
details: irrDetails,
summary: { good: goodCount, warning: warnCount, error: errorCount, total: prefixes.length },
source: 'NLNOG IRR Explorer'
}));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── AS-SET Expander ───────────────────────────────────────────
if (reqPath.startsWith('/api/asset-expand')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const setName = params.get('set') || '';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
if (!setName) { res.writeHead(400); return res.end(JSON.stringify({error:'set required (e.g. AS-FLEXOPTIX)'})); }
try {
async function expandSet(name, depth, visited) {
if (depth > 4 || visited.has(name)) return { asns: [], sets: [] };
visited.add(name);
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(name)}&type-filter=as-set&flags=no-referenced`;
const data = await fetchJSONWithRetry(url, { timeout: 10000 });
const asns = [], sets = [];
if (data && data.objects && data.objects.object) {
for (const obj of data.objects.object) {
const attrs = obj.attributes && obj.attributes.attribute || [];
for (const a of attrs) {
if (a.name === 'members') {
for (const m of (a.value || '').split(/[,\s]+/).filter(Boolean)) {
if (/^AS\d+$/i.test(m)) asns.push(m.toUpperCase());
else if (m.startsWith('AS-')) { sets.push(m); }
}
}
}
}
}
for (const sub of sets.slice(0,10)) {
const sub_r = await expandSet(sub, depth+1, visited);
asns.push(...sub_r.asns);
}
return { asns: [...new Set(asns)], sets };
}
const visited = new Set();
const result = await expandSet(setName.toUpperCase(), 0, visited);
result.asns.sort((a,b) => parseInt(a.slice(2)) - parseInt(b.slice(2)));
res.writeHead(200);
return res.end(JSON.stringify({ set: setName.toUpperCase(), count: result.asns.length, asns: result.asns, sub_sets: result.sets }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── Routing History (prefix table via RIPE Stat routing-history) ──
if (reqPath.startsWith('/api/rpki-history')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
const url = 'https://stat.ripe.net/data/routing-history/data.json?resource=AS' + asn + '&max_rows=100';
const data = await fetchJSONWithRetry(url, { timeout: 20000 });
const byOrigin = data && data.data && data.data.by_origin || [];
// Flatten: each origin entry has prefixes[]
var prefixes = [];
for (var i = 0; i < byOrigin.length; i++) {
var orig = byOrigin[i];
if (orig.prefixes) {
for (var j = 0; j < orig.prefixes.length; j++) {
prefixes.push(orig.prefixes[j]);
}
}
}
res.writeHead(200);
return res.end(JSON.stringify({ asn: asn, prefixes: prefixes, source: 'RIPE Stat routing-history' }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
if (reqPath.startsWith('/api/aspath')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=300');
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
try {
// Use RIPE Stat announced-prefixes to get prefixes, then looking-glass for paths
var annUrl = 'https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS' + asn;
var annData = await fetchJSONWithRetry(annUrl, { timeout: 15000 });
var announced = annData && annData.data && annData.data.prefixes || [];
var prefix = announced.length > 0 ? announced[0].prefix : null;
if (!prefix) {
res.writeHead(200);
return res.end(JSON.stringify({ asn: asn, paths: [], source: 'RIPE Stat' }));
}
// Get looking-glass data for the first announced prefix
var lgUrl = 'https://stat.ripe.net/data/looking-glass/data.json?resource=' + encodeURIComponent(prefix);
var lgData = await fetchJSONWithRetry(lgUrl, { timeout: 20000 });
var rrcs = lgData && lgData.data && lgData.data.rrcs || [];
var paths = [];
var seen = new Set();
for (var i = 0; i < rrcs.length && paths.length < 10; i++) {
var rrc = rrcs[i];
var peers = rrc.peers || [];
for (var j = 0; j < peers.length && paths.length < 10; j++) {
var p = peers[j];
var pathStr = (p.as_path || '').trim();
if (pathStr && !seen.has(pathStr)) {
seen.add(pathStr);
paths.push({
path: pathStr,
prefix: prefix,
rrc: rrc.rrc + ' (' + (rrc.location||'') + ')',
peer_asn: p.asn_origin || ''
});
}
}
}
res.writeHead(200);
return res.end(JSON.stringify({ asn: asn, paths: paths, prefix: prefix, source: 'RIPE Stat looking-glass · ' + prefix }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── Looking Glass (RIPE Stat) ─────────────────────────────────
if (reqPath.startsWith('/api/looking-glass')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const resource = params.get('prefix') || params.get('asn') || '';
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!resource) { res.writeHead(400); return res.end(JSON.stringify({error:'prefix or asn required'})); }
try {
const url = `https://stat.ripe.net/data/looking-glass/data.json?resource=${encodeURIComponent(resource)}`;
const data = await fetchJSONWithRetry(url, { timeout: 20000 });
const rrcs = data && data.data && data.data.rrcs || [];
const results = rrcs.slice(0, 15).map(rrc => ({
rrc: rrc.rrc,
location: rrc.location,
peers: (rrc.peers || []).slice(0,5).map(p => ({
asn: p.asn_origin,
as_path: p.as_path,
community: p.community,
next_hop: p.next_hop,
}))
}));
res.writeHead(200);
return res.end(JSON.stringify({ resource, rrcs: results, total_rrcs: rrcs.length }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── IXP Peering Matrix ────────────────────────────────────────
if (reqPath.startsWith('/api/ix-matrix')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const ixId = (params.get('ix_id') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
if (!ixId) { res.writeHead(400); return res.end(JSON.stringify({error:'ix_id required'})); }
try {
const [netixData, ixData] = await Promise.all([
fetchJSONWithRetry(`${PEERINGDB_API_URL}/netixlan?ix_id=${ixId}&depth=1&limit=200`, { timeout: 15000 }),
fetchJSONWithRetry(`${PEERINGDB_API_URL}/ix/${ixId}`, { timeout: 10000 }),
]);
const ix = ixData && ixData.data && ixData.data[0];
const members = (netixData && netixData.data || []).map(m => ({
asn: m.asn, name: m.name, speed: m.speed, ipaddr4: m.ipaddr4, ipaddr6: m.ipaddr6, policy: m.policy_general
}));
members.sort((a,b) => (b.speed||0) - (a.speed||0));
res.writeHead(200);
return res.end(JSON.stringify({ ix_id: ixId, ix_name: ix && ix.name, ix_city: ix && ix.city, members, member_count: members.length }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// ── Hijack Subscribe ──────────────────────────────────────────
if (reqPath === '/api/hijack-subscribe' && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
let body = '';
req.on('data', c => body += c);
req.on('end', async () => {
try {
const { asn, email } = JSON.parse(body);
const asnNum = String(asn).replace(/[^0-9]/g,'');
if (!asnNum) { res.writeHead(400); return res.end(JSON.stringify({error:'asn required'})); }
const subs = loadHijackSubs();
const exists = subs.find(s => s.asn === asnNum);
if (!exists) {
const prefixes = await checkHijacksForAsn(asnNum);
subs.push({ asn: asnNum, email: email || '', prefixes, subscribed: new Date().toISOString() });
fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2));
}
res.writeHead(200);
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: exists ? exists.prefixes.length : subs[subs.length-1].prefixes.length }));
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
});
return;
}
if (reqPath === '/api/hijack-subscribe' && req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin','*');
res.setHeader('Access-Control-Allow-Methods','POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers','Content-Type');
res.writeHead(204); return res.end();
}
// ── Hijack Alerts ─────────────────────────────────────────────
if (reqPath.startsWith('/api/hijack-alerts')) {
const params = new URL(req.url, 'http://localhost').searchParams;
const asn = (params.get('asn') || '').replace(/[^0-9]/g,'');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
const allAlerts = loadHijackAlerts();
const alerts = asn ? allAlerts.filter(a => a.asn === asn) : allAlerts;
const subs = loadHijackSubs();
const sub = subs.find(s => s.asn === asn);
res.writeHead(200);
return res.end(JSON.stringify({ asn, alerts: alerts.slice(-50), monitoring: !!sub, prefix_count: sub ? sub.prefixes.length : 0 }));
}
// ── Changelog JSON API ────────────────────────────────────────
if (reqPath === '/changelog-data') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
try {
const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8');
const entries = [];
let current = null;
let currentSection = null;
for (const line of md.split('\n')) {
// Support both ## [0.6.x] — date AND ## v0.6.x — date
const vMatch = line.match(/^## (?:v|\[)?([\d.]+)\]? — (.+)/);
if (vMatch) {
if (current) entries.push(current);
current = { version: vMatch[1], date: vMatch[2].trim(), sections: [] };
currentSection = null;
continue;
}
const sMatch = line.match(/^### (.+)/);
if (sMatch && current) {
currentSection = { name: sMatch[1], items: [] };
current.sections.push(currentSection);
continue;
}
const iMatch = line.match(/^- (.+)/);
if (iMatch && currentSection) {
currentSection.items.push(iMatch[1]);
}
}
if (current) entries.push(current);
res.writeHead(200);
return res.end(JSON.stringify(entries));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({error:e.message}));
}
}
// 404 // 404
res.writeHead(404); res.writeHead(404);
res.end( res.end(
@ -4116,7 +4847,7 @@ loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");
ensureManrsCache(); // fire-and-forget, 24h cache ensureManrsCache(); // fire-and-forget, 24h cache
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => { Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
server.listen(PORT, "0.0.0.0", () => { server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.6.0 running on http://0.0.0.0:" + PORT); console.log("PeerCortex v0.6.1 running on http://0.0.0.0:" + PORT);
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured")); console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured"));
console.log("RPKI ASPA objects: " + rpkiAspaMap.size); console.log("RPKI ASPA objects: " + rpkiAspaMap.size);

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

1
visitors.json Normal file

File diff suppressed because one or more lines are too long