feat: add RS column, contacts, timing panel, JSON export, city (v0.6.6)
This commit is contained in:
parent
6fb0eb86af
commit
32bb279c1d
1
.pdb-org-cache.json
Normal file
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
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
1
.ripe-stat-cache.json
Normal file
File diff suppressed because one or more lines are too long
1
.roa-cache.json
Normal file
1
.roa-cache.json
Normal file
File diff suppressed because one or more lines are too long
166
CHANGELOG.md
166
CHANGELOG.md
@ -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** (0–100) 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** (0–100) across four dimensions: ROA coverage, ASPA object presence, provider match completeness, path validation rate.
|
||||||
|
- **Provider Audit** — compares RPKI-declared providers vs BGP-detected providers, highlights gaps.
|
||||||
|
- **Network Health Report** — 13 automated checks with traffic-light scoring (Bogon, RPKI ROA, Blocklist, IRR, MANRS, BGP Visibility, Reverse DNS, Abuse Contact, Resource Cert, IX Route Servers, BGP Communities, Geolocation, IRR Object).
|
||||||
|
- **RIPE Atlas probe integration** — total probes, connected/disconnected counts, anchors per ASN.
|
||||||
|
- **bgproutes.io integration** — 3,000+ vantage points, RIB queries, ROV and ASPA status.
|
||||||
|
- **Network Compare** — side-by-side comparison of two ASNs (common IXPs, shared upstreams, overlapping facilities).
|
||||||
|
- **Recent Lookups** with quick-click history badges.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- ASPA objects not detected — switched from broken RIPE DB remarks search to Cloudflare RPKI JSON feed.
|
||||||
|
- Various frontend rendering bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4.0 — 2026-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial public release.
|
||||||
|
- ASN lookup dashboard with PeeringDB, RIPE Stat, RIPE Atlas, bgproutes.io, and Cloudflare RPKI integration.
|
||||||
|
- Per-prefix RPKI validation.
|
||||||
|
- AS neighbour resolution with names.
|
||||||
|
- IX presence, facilities, and peering policy display.
|
||||||
|
- Network Compare tool.
|
||||||
|
- Live at peercortex.org.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
| Source | 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.
|
||||||
|
|||||||
BIN
audit/__pycache__/audit.cpython-312.pyc
Normal file
BIN
audit/__pycache__/audit.cpython-312.pyc
Normal file
Binary file not shown.
1210
audit/asn_registry.json
Normal file
1210
audit/asn_registry.json
Normal file
File diff suppressed because it is too large
Load Diff
56
audit/latest_report.txt
Normal file
56
audit/latest_report.txt
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
============================================================
|
||||||
|
PeerCortex Daily Audit — 2026-03-30 (2026-03-30T05:58:34Z)
|
||||||
|
============================================================
|
||||||
|
Batch: 100 ASNs | PDB key: ACTIVE
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
AUDIT SUMMARY — 2026-03-30
|
||||||
|
============================================================
|
||||||
|
Audited : 100 ASNs
|
||||||
|
Passed : 91 (91%)
|
||||||
|
Failed : 9
|
||||||
|
No PDB : 0 (fac=0 ix=0 is CORRECT — not an error)
|
||||||
|
PDB err : 24 (IX/fac skipped — PDB fetch failed, will retry next run)
|
||||||
|
PDB Key : Active (no rate limits)
|
||||||
|
Trend : 87% → 91% (+4%)
|
||||||
|
|
||||||
|
Timeouts: AS174
|
||||||
|
|
||||||
|
TOP DISCREPANCIES:
|
||||||
|
ASN Field Auth PeerCortex Delta
|
||||||
|
------------------------------------------------------------------
|
||||||
|
AS9002 Neighbours (downstream) 0 2702 2702
|
||||||
|
AS9002 Neighbours (upstream) 0 146 146
|
||||||
|
AS212232 IXPs 0 128 128
|
||||||
|
AS37468 Facilities 0 35 35
|
||||||
|
AS61955 IXPs 0 16 16
|
||||||
|
AS9318 IXPs 0 15 15
|
||||||
|
AS61955 Facilities 0 10 10
|
||||||
|
AS9318 Facilities 0 8 8
|
||||||
|
AS37239 Facilities 4 0 4
|
||||||
|
AS212232 Facilities 0 3 3
|
||||||
|
AS17469 IXPs 0 2 2
|
||||||
|
AS206479 Facilities 0 1 1
|
||||||
|
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
KNOWN ISSUES (1 ASNs with persistent failures)
|
||||||
|
These remain until the data is correct in PeerCortex.
|
||||||
|
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
|
||||||
|
AS9002 RETN [OPEN — 2 consecutive failures, first seen: 2026-03-30]
|
||||||
|
▸ Neighbours (upstream):
|
||||||
|
PeerCortex returns 146 but authoritative source shows 0. Likely cause: PeerCortex using stale cached data or querying wrong endpoint.
|
||||||
|
auth=0 pc=146 seen 1x last: 2026-03-30
|
||||||
|
▸ Neighbours (downstream):
|
||||||
|
PeerCortex returns 2702 but authoritative source shows 0. Likely cause: PeerCortex using stale cached data or querying wrong endpoint.
|
||||||
|
auth=0 pc=2702 seen 1x last: 2026-03-30
|
||||||
|
|
||||||
|
|
||||||
|
DATABASE HEALTH:
|
||||||
|
Total tracked ASNs : 100
|
||||||
|
Clean streak : 91 ASNs with 0 consecutive errors
|
||||||
|
Open known issues : 1 ASNs
|
||||||
|
Ever had errors : 57 ASNs
|
||||||
|
|
||||||
|
Report: /opt/peercortex-app/audit/reports/2026-03-30.json
|
||||||
2270
audit/reports/2026-03-28.json
Normal file
2270
audit/reports/2026-03-28.json
Normal file
File diff suppressed because it is too large
Load Diff
2044
audit/reports/2026-03-30.json
Normal file
2044
audit/reports/2026-03-30.json
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_115951
Normal file
2693
backups/index.html.20260327_115951
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_121113
Normal file
2693
backups/index.html.20260327_121113
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_121443
Normal file
2693
backups/index.html.20260327_121443
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_124301
Normal file
2693
backups/index.html.20260327_124301
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_124716
Normal file
2693
backups/index.html.20260327_124716
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_131710
Normal file
2693
backups/index.html.20260327_131710
Normal file
File diff suppressed because it is too large
Load Diff
2693
backups/index.html.20260327_131812
Normal file
2693
backups/index.html.20260327_131812
Normal file
File diff suppressed because it is too large
Load Diff
2772
backups/index.html.20260327_133135
Normal file
2772
backups/index.html.20260327_133135
Normal file
File diff suppressed because it is too large
Load Diff
3024
backups/server.js.20260327_115951
Normal file
3024
backups/server.js.20260327_115951
Normal file
File diff suppressed because it is too large
Load Diff
3029
backups/server.js.20260327_121113
Normal file
3029
backups/server.js.20260327_121113
Normal file
File diff suppressed because it is too large
Load Diff
3028
backups/server.js.20260327_121443
Normal file
3028
backups/server.js.20260327_121443
Normal file
File diff suppressed because it is too large
Load Diff
3059
backups/server.js.20260327_124301
Normal file
3059
backups/server.js.20260327_124301
Normal file
File diff suppressed because it is too large
Load Diff
3069
backups/server.js.20260327_124716
Normal file
3069
backups/server.js.20260327_124716
Normal file
File diff suppressed because it is too large
Load Diff
3070
backups/server.js.20260327_131710
Normal file
3070
backups/server.js.20260327_131710
Normal file
File diff suppressed because it is too large
Load Diff
3071
backups/server.js.20260327_131812
Normal file
3071
backups/server.js.20260327_131812
Normal file
File diff suppressed because it is too large
Load Diff
3074
backups/server.js.20260327_133135
Normal file
3074
backups/server.js.20260327_133135
Normal file
File diff suppressed because it is too large
Load Diff
12
deploy.sh
Executable file
12
deploy.sh
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PeerCortex safe deploy script — always backup before restart
|
||||||
|
BACKUP_DIR=/opt/peercortex-app/backups
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
TS=$(date +%Y%m%d_%H%M%S)
|
||||||
|
cp /opt/peercortex-app/server.js $BACKUP_DIR/server.js.$TS
|
||||||
|
cp /opt/peercortex-app/public/index.html $BACKUP_DIR/index.html.$TS
|
||||||
|
# Keep only last 20 backups
|
||||||
|
ls -t $BACKUP_DIR/server.js.* 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
||||||
|
ls -t $BACKUP_DIR/index.html.* 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
||||||
|
echo "[backup] Saved server.js + index.html ($TS)"
|
||||||
|
pm2 restart peercortex
|
||||||
72
feedback.json
Normal file
72
feedback.json
Normal 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
11
hijack-subs.json
Normal 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
6007
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
2898
public/index-classic.html
Normal file
File diff suppressed because it is too large
Load Diff
2076
public/index.html
2076
public/index.html
File diff suppressed because it is too large
Load Diff
2357
public/index.html.bak
Normal file
2357
public/index.html.bak
Normal file
File diff suppressed because it is too large
Load Diff
488
public/lia.html
Normal file
488
public/lia.html
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Lia's Paradise — RIPE Atlas Coverage Explorer</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
:root{--bg:#0f0f1a;--card:#1a1b26;--border:#2a2b3d;--border-light:#363750;--text:#e2e8f0;--text-dim:#94a3b8;--muted:#64748b;--dim:#475569;--purple:#a78bfa;--blue:#60a5fa;--green:#4ade80;--orange:#fbbf24;--red:#f87171;--cyan:#22d3ee;--pink:#f472b6}
|
||||||
|
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||||
|
.header{background:linear-gradient(135deg,#1a1b26 0%,#1e1f30 100%);border-bottom:1px solid var(--border);padding:1.5rem 2rem;text-align:center}
|
||||||
|
.header h1{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--pink),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.25rem}
|
||||||
|
.header p{color:var(--text-dim);font-size:.85rem}
|
||||||
|
.easter-egg{font-size:.65rem;color:var(--dim);margin-top:.25rem;font-style:italic}
|
||||||
|
.controls{max-width:1400px;margin:1.5rem auto;padding:0 1.5rem;display:flex;gap:1rem;flex-wrap:wrap;align-items:center}
|
||||||
|
.search-box{flex:1;min-width:200px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.7rem 1rem;font-size:.9rem;color:var(--text);font-family:inherit;outline:none}
|
||||||
|
.search-box:focus{border-color:var(--purple)}
|
||||||
|
.search-box::placeholder{color:var(--dim)}
|
||||||
|
.rir-tabs{display:flex;gap:.4rem;flex-wrap:wrap}
|
||||||
|
.rir-tab{padding:.5rem 1rem;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text-dim);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||||
|
.rir-tab:hover{border-color:var(--purple);color:var(--text)}
|
||||||
|
.rir-tab.active{background:linear-gradient(135deg,#5b21b6,#7c3aed);color:#fff;border-color:#7c3aed}
|
||||||
|
.export-btn{padding:.5rem 1.2rem;border-radius:8px;border:1px solid var(--cyan);background:transparent;color:var(--cyan);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit}
|
||||||
|
.export-btn:hover{background:var(--cyan);color:var(--bg)}
|
||||||
|
.stats-bar{max-width:1400px;margin:.75rem auto;padding:0 1.5rem;display:flex;gap:1.5rem;flex-wrap:wrap}
|
||||||
|
.stat-pill{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.5rem 1rem;display:flex;align-items:center;gap:.5rem}
|
||||||
|
.stat-pill .num{font-size:1.1rem;font-weight:800}
|
||||||
|
.stat-pill .label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
.content{max-width:1400px;margin:1rem auto;padding:0 1.5rem}
|
||||||
|
.loading{text-align:center;padding:3rem;color:var(--muted);font-size:.9rem}
|
||||||
|
.loading .spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;margin-right:.5rem;vertical-align:middle}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.country-section{margin-bottom:1.5rem}
|
||||||
|
.country-header{display:flex;align-items:center;gap:.75rem;padding:.6rem 1rem;background:var(--card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .2s;margin-bottom:.5rem}
|
||||||
|
.country-header:hover{border-color:var(--purple)}
|
||||||
|
.country-flag{font-size:1.2rem}
|
||||||
|
.country-name{font-weight:700;font-size:.95rem;flex:1}
|
||||||
|
.country-count{font-size:.75rem;color:var(--muted)}
|
||||||
|
.country-badge{padding:.2rem .6rem;border-radius:6px;font-size:.7rem;font-weight:700}
|
||||||
|
.badge-red{background:#f8717118;color:var(--red);border:1px solid #f8717130}
|
||||||
|
.badge-green{background:#4ade8018;color:var(--green);border:1px solid #4ade8030}
|
||||||
|
.badge-orange{background:#fbbf2418;color:var(--orange);border:1px solid #fbbf2430}
|
||||||
|
.asn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.4rem;padding:0 0 .5rem 0}
|
||||||
|
.asn-row{display:flex;align-items:center;gap:.65rem;padding:.5rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:.8rem;transition:all .15s;cursor:pointer}
|
||||||
|
.asn-row:hover{border-color:var(--purple);transform:translateY(-1px)}
|
||||||
|
.asn-num{font-weight:700;color:var(--purple);min-width:80px;font-family:'JetBrains Mono',monospace}
|
||||||
|
.asn-name{flex:1;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.asn-type{font-size:.65rem;padding:.15rem .4rem;border-radius:4px;background:var(--card);color:var(--dim);border:1px solid var(--border)}
|
||||||
|
.no-probe{color:var(--red);font-size:.65rem;font-weight:600}
|
||||||
|
.has-probe{color:var(--green);font-size:.65rem;font-weight:600}
|
||||||
|
.summary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
||||||
|
.summary-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;text-align:center}
|
||||||
|
.summary-card .rir-name{font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem}
|
||||||
|
.summary-card .big-num{font-size:2rem;font-weight:800}
|
||||||
|
.summary-card .sub{font-size:.7rem;color:var(--dim);margin-top:.25rem}
|
||||||
|
.progress-bar{height:6px;background:var(--bg);border-radius:3px;margin-top:.5rem;overflow:hidden}
|
||||||
|
.progress-fill{height:100%;border-radius:3px;transition:width .5s ease}
|
||||||
|
.hidden{display:none}
|
||||||
|
.footer{text-align:center;padding:2rem;color:var(--dim);font-size:.7rem;margin-top:2rem;border-top:1px solid var(--border)}
|
||||||
|
.footer a{color:var(--purple);text-decoration:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Lia's Paradise</h1>
|
||||||
|
<p>RIPE Atlas Coverage Explorer — Networks without Atlas Probes, Anchors & Software Probes</p>
|
||||||
|
<div class="easter-egg">For Lia, who makes the Internet measurable — one probe at a time</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<input type="text" class="search-box" id="searchInput" placeholder="Search by country, ASN, or network name..." oninput="filterResults()">
|
||||||
|
<div class="rir-tabs" id="rirTabs">
|
||||||
|
<button class="rir-tab active" onclick="switchRIR('all',this)">All RIRs</button>
|
||||||
|
<button class="rir-tab" onclick="switchRIR('ripencc',this)">RIPE NCC</button>
|
||||||
|
<button class="rir-tab" onclick="switchRIR('arin',this)">ARIN</button>
|
||||||
|
<button class="rir-tab" onclick="switchRIR('apnic',this)">APNIC</button>
|
||||||
|
<button class="rir-tab" onclick="switchRIR('lacnic',this)">LACNIC</button>
|
||||||
|
<button class="rir-tab" onclick="switchRIR('afrinic',this)">AFRINIC</button>
|
||||||
|
</div>
|
||||||
|
<button class="export-btn" onclick="exportPDF()">Export PDF</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="max-width:1400px;margin:.75rem auto;padding:0 1.5rem">
|
||||||
|
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1rem 1.5rem">
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||||
|
<div style="font-size:.8rem;font-weight:600;color:var(--pink)">FILE LOOKUP</div>
|
||||||
|
<div style="font-size:.75rem;color:var(--dim)">Upload a file with ASNs or company names — we'll check probe coverage for each</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<label style="padding:.45rem 1rem;border-radius:8px;border:1px solid var(--pink);background:transparent;color:var(--pink);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:.4rem" onmouseenter="this.style.background='var(--pink)';this.style.color='var(--bg)'" onmouseleave="this.style.background='transparent';this.style.color='var(--pink)'">
|
||||||
|
<span>Upload File</span>
|
||||||
|
<input type="file" id="fileUpload" accept=".csv,.txt,.pdf,.xls,.xlsx,.doc,.docx" style="display:none" onchange="handleFileUpload(this)">
|
||||||
|
</label>
|
||||||
|
<span id="fileStatus" style="font-size:.7rem;color:var(--dim)"></span>
|
||||||
|
</div>
|
||||||
|
<div id="fileResults" class="hidden" style="margin-top:1rem"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="statsBar"></div>
|
||||||
|
|
||||||
|
<div id="summaryGrid" class="summary-grid" style="max-width:1400px;margin:1rem auto;padding:0 1.5rem"></div>
|
||||||
|
|
||||||
|
<div class="content" id="mainContent">
|
||||||
|
<div class="loading"><span class="spinner"></span>Loading Atlas coverage data across all RIRs... This may take a moment.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<a href="/">Back to PeerCortex</a> · Data from <a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a> & <a href="https://www.peeringdb.com" target="_blank">PeeringDB</a>
|
||||||
|
<br>Made with love for the Atlas community
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var allData = [];
|
||||||
|
var currentRIR = 'all';
|
||||||
|
var countryFlags = {};
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
// Country code to flag emoji
|
||||||
|
function flag(cc) {
|
||||||
|
if (!cc || cc.length !== 2) return '';
|
||||||
|
return String.fromCodePoint(...cc.toUpperCase().split('').map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
|
||||||
|
}
|
||||||
|
|
||||||
|
// RIR mapping by country
|
||||||
|
var rirMap = {
|
||||||
|
ripencc: new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IS','IE','IT','LV','LT','LU','MT','NL','NO','PL','PT','RO','SK','SI','ES','SE','CH','GB','UA','RU','TR','GE','AZ','AM','MD','BY','RS','BA','ME','MK','AL','XK','LI','MC','SM','VA','AD','FO','GL','AX','GG','JE','IM','BH','IQ','IR','IL','JO','KW','LB','OM','PS','QA','SA','SY','AE','YE','DZ','EG','LY','MA','TN','EH']),
|
||||||
|
arin: new Set(['US','CA','PR','VI','GU','AS','MP','MH','FM','PW','UM']),
|
||||||
|
apnic: new Set(['AU','NZ','JP','KR','CN','HK','MO','TW','IN','BD','PK','LK','NP','BT','MV','AF','MM','TH','VN','LA','KH','MY','SG','ID','PH','BN','TL','PG','FJ','WS','TO','VU','SB','KI','NR','TV','MN','KZ','KG','TJ','TM','UZ']),
|
||||||
|
lacnic: new Set(['MX','GT','BZ','SV','HN','NI','CR','PA','CO','VE','EC','PE','BO','CL','AR','UY','PY','BR','GY','SR','GF','CU','JM','HT','DO','TT','BB','AG','DM','GD','KN','LC','VC','BS','CW','AW','SX','BQ','TC','KY','BM']),
|
||||||
|
afrinic: new Set(['ZA','NG','KE','GH','TZ','UG','ET','RW','SN','CI','CM','CD','CG','GA','AO','MZ','ZW','ZM','MW','BW','NA','SZ','LS','MG','MU','SC','DJ','ER','SO','SD','SS','TD','NE','ML','BF','GW','GN','SL','LR','TG','BJ','CF','GQ']),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRIR(cc) {
|
||||||
|
if (!cc) return 'unknown';
|
||||||
|
for (var r in rirMap) { if (rirMap[r].has(cc.toUpperCase())) return r; }
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
// Fetch PeeringDB networks with IX presence (active networks)
|
||||||
|
var resp = await fetch('/api/lia/coverage');
|
||||||
|
var d = await resp.json();
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
allData = d.networks || [];
|
||||||
|
renderAll();
|
||||||
|
} catch (e) {
|
||||||
|
$('mainContent').innerHTML = '<div class="loading" style="color:var(--red)">Failed to load: ' + escHtml(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
renderSummary();
|
||||||
|
renderStats();
|
||||||
|
renderCountries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
var rir_stats = { ripencc: {total:0,noProbe:0}, arin: {total:0,noProbe:0}, apnic: {total:0,noProbe:0}, lacnic: {total:0,noProbe:0}, afrinic: {total:0,noProbe:0} };
|
||||||
|
var rirLabels = { ripencc:'RIPE NCC', arin:'ARIN', apnic:'APNIC', lacnic:'LACNIC', afrinic:'AFRINIC' };
|
||||||
|
var rirColors = { ripencc:'var(--blue)', arin:'var(--green)', apnic:'var(--orange)', lacnic:'var(--pink)', afrinic:'var(--cyan)' };
|
||||||
|
|
||||||
|
allData.forEach(function(n) {
|
||||||
|
var r = getRIR(n.country);
|
||||||
|
if (rir_stats[r]) {
|
||||||
|
rir_stats[r].total++;
|
||||||
|
if (!n.has_probe) rir_stats[r].noProbe++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var h = '';
|
||||||
|
for (var r in rir_stats) {
|
||||||
|
var s = rir_stats[r];
|
||||||
|
var covPct = s.total > 0 ? ((s.total - s.noProbe) / s.total * 100).toFixed(1) : '0';
|
||||||
|
var col = rirColors[r];
|
||||||
|
h += '<div class="summary-card">';
|
||||||
|
h += '<div class="rir-name">' + rirLabels[r] + '</div>';
|
||||||
|
h += '<div class="big-num" style="color:' + col + '">' + s.noProbe.toLocaleString() + '</div>';
|
||||||
|
h += '<div class="sub">of ' + s.total.toLocaleString() + ' networks without a probe</div>';
|
||||||
|
h += '<div class="progress-bar"><div class="progress-fill" style="width:' + covPct + '%;background:' + col + '"></div></div>';
|
||||||
|
h += '<div class="sub" style="margin-top:.25rem">' + covPct + '% coverage</div>';
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
$('summaryGrid').innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
var filtered = getFiltered();
|
||||||
|
var noProbe = filtered.filter(function(n) { return !n.has_probe; }).length;
|
||||||
|
var withProbe = filtered.length - noProbe;
|
||||||
|
var countries = new Set(filtered.map(function(n) { return n.country; })).size;
|
||||||
|
|
||||||
|
var h = '';
|
||||||
|
h += '<div class="stat-pill"><span class="num" style="color:var(--red)">' + noProbe.toLocaleString() + '</span><span class="label">Without Probe</span></div>';
|
||||||
|
h += '<div class="stat-pill"><span class="num" style="color:var(--green)">' + withProbe.toLocaleString() + '</span><span class="label">With Probe</span></div>';
|
||||||
|
h += '<div class="stat-pill"><span class="num" style="color:var(--purple)">' + filtered.length.toLocaleString() + '</span><span class="label">Total Networks</span></div>';
|
||||||
|
h += '<div class="stat-pill"><span class="num" style="color:var(--cyan)">' + countries + '</span><span class="label">Countries</span></div>';
|
||||||
|
$('statsBar').innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFiltered() {
|
||||||
|
var q = ($('searchInput').value || '').toLowerCase();
|
||||||
|
return allData.filter(function(n) {
|
||||||
|
if (currentRIR !== 'all' && getRIR(n.country) !== currentRIR) return false;
|
||||||
|
if (q) {
|
||||||
|
var haystack = (n.name + ' ' + n.country + ' ' + n.country_name + ' AS' + n.asn + ' ' + (n.info_type || '')).toLowerCase();
|
||||||
|
if (haystack.indexOf(q) < 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCountries() {
|
||||||
|
var filtered = getFiltered();
|
||||||
|
|
||||||
|
// Group by country
|
||||||
|
var byCountry = {};
|
||||||
|
filtered.forEach(function(n) {
|
||||||
|
var cc = n.country || 'XX';
|
||||||
|
if (!byCountry[cc]) byCountry[cc] = { name: n.country_name || cc, networks: [] };
|
||||||
|
byCountry[cc].networks.push(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort countries by number of networks without probes (descending)
|
||||||
|
var countries = Object.keys(byCountry).sort(function(a, b) {
|
||||||
|
var aNo = byCountry[a].networks.filter(function(n) { return !n.has_probe; }).length;
|
||||||
|
var bNo = byCountry[b].networks.filter(function(n) { return !n.has_probe; }).length;
|
||||||
|
return bNo - aNo;
|
||||||
|
});
|
||||||
|
|
||||||
|
var h = '';
|
||||||
|
countries.forEach(function(cc) {
|
||||||
|
var c = byCountry[cc];
|
||||||
|
var noProbe = c.networks.filter(function(n) { return !n.has_probe; });
|
||||||
|
var withProbe = c.networks.filter(function(n) { return n.has_probe; });
|
||||||
|
var covPct = (withProbe.length / c.networks.length * 100).toFixed(0);
|
||||||
|
var badgeClass = covPct >= 70 ? 'badge-green' : covPct >= 30 ? 'badge-orange' : 'badge-red';
|
||||||
|
var secId = 'country_' + cc;
|
||||||
|
|
||||||
|
h += '<div class="country-section">';
|
||||||
|
h += '<div class="country-header" onclick="var el=document.getElementById(\'' + secId + '\');el.classList.toggle(\'hidden\');this.querySelector(\'.arrow\').textContent=el.classList.contains(\'hidden\')?\'\u25B6\':\'\u25BC\'">';
|
||||||
|
h += '<span class="country-flag">' + flag(cc) + '</span>';
|
||||||
|
h += '<span class="country-name">' + escHtml(c.name) + ' (' + cc + ')</span>';
|
||||||
|
h += '<span class="country-count">' + noProbe.length + ' without probe / ' + c.networks.length + ' total</span>';
|
||||||
|
h += '<span class="' + badgeClass + ' country-badge">' + covPct + '% coverage</span>';
|
||||||
|
h += '<span class="arrow" style="color:var(--muted)">\u25B6</span>';
|
||||||
|
h += '</div>';
|
||||||
|
|
||||||
|
h += '<div id="' + secId + '" class="hidden">';
|
||||||
|
// Show networks WITHOUT probes first
|
||||||
|
if (noProbe.length > 0) {
|
||||||
|
h += '<div style="font-size:.7rem;color:var(--red);font-weight:600;margin:.5rem 0 .3rem;padding-left:.5rem">NO PROBE (' + noProbe.length + ')</div>';
|
||||||
|
h += '<div class="asn-grid">';
|
||||||
|
noProbe.sort(function(a,b) { return a.asn - b.asn; }).forEach(function(n) {
|
||||||
|
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + n.asn + '\',\'_blank\')">';
|
||||||
|
h += '<span class="asn-num">AS' + n.asn + '</span>';
|
||||||
|
h += '<span class="asn-name">' + escHtml(n.name || '') + '</span>';
|
||||||
|
if (n.info_type) h += '<span class="asn-type">' + escHtml(n.info_type) + '</span>';
|
||||||
|
h += '<span class="no-probe">\u2718 No Probe</span>';
|
||||||
|
h += '</div>';
|
||||||
|
});
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
// Networks WITH probes
|
||||||
|
if (withProbe.length > 0) {
|
||||||
|
h += '<div style="font-size:.7rem;color:var(--green);font-weight:600;margin:.75rem 0 .3rem;padding-left:.5rem">HAS PROBE (' + withProbe.length + ')</div>';
|
||||||
|
h += '<div class="asn-grid">';
|
||||||
|
withProbe.sort(function(a,b) { return a.asn - b.asn; }).slice(0, 20).forEach(function(n) {
|
||||||
|
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + n.asn + '\',\'_blank\')">';
|
||||||
|
h += '<span class="asn-num">AS' + n.asn + '</span>';
|
||||||
|
h += '<span class="asn-name">' + escHtml(n.name || '') + '</span>';
|
||||||
|
if (n.info_type) h += '<span class="asn-type">' + escHtml(n.info_type) + '</span>';
|
||||||
|
h += '<span class="has-probe">\u2714 Probe</span>';
|
||||||
|
h += '</div>';
|
||||||
|
});
|
||||||
|
if (withProbe.length > 20) h += '<div style="font-size:.75rem;color:var(--dim);padding:.3rem .5rem">+ ' + (withProbe.length - 20) + ' more with probes</div>';
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
h += '</div>';
|
||||||
|
h += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (countries.length === 0) {
|
||||||
|
h = '<div class="loading">No results found. Try a different search or RIR filter.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('mainContent').innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchRIR(rir, btn) {
|
||||||
|
currentRIR = rir;
|
||||||
|
document.querySelectorAll('.rir-tab').forEach(function(t) { t.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
renderStats();
|
||||||
|
renderCountries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterResults() {
|
||||||
|
renderStats();
|
||||||
|
renderCountries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPDF() {
|
||||||
|
// Generate a printable version
|
||||||
|
var w = window.open('', '_blank');
|
||||||
|
var filtered = getFiltered().filter(function(n) { return !n.has_probe; });
|
||||||
|
var byCountry = {};
|
||||||
|
filtered.forEach(function(n) {
|
||||||
|
var cc = n.country || 'XX';
|
||||||
|
if (!byCountry[cc]) byCountry[cc] = { name: n.country_name || cc, networks: [] };
|
||||||
|
byCountry[cc].networks.push(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '<!DOCTYPE html><html><head><title>Atlas Coverage Report — Lia\'s Paradise</title>';
|
||||||
|
html += '<style>body{font-family:Arial,sans-serif;margin:2rem;color:#1a1a2e}h1{color:#5b21b6}h2{color:#7c3aed;margin-top:1.5rem;border-bottom:2px solid #e2e8f0;padding-bottom:.3rem}table{width:100%;border-collapse:collapse;margin:.5rem 0 1rem}th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #e2e8f0;font-size:.85rem}th{background:#f8fafc;font-weight:600}.no-probe{color:#dc2626;font-weight:600}.meta{color:#64748b;font-size:.8rem;margin-bottom:2rem}</style></head><body>';
|
||||||
|
html += '<h1>RIPE Atlas Coverage Report</h1>';
|
||||||
|
html += '<div class="meta">Generated by Lia\'s Paradise (peercortex.org/lia) on ' + new Date().toISOString().split('T')[0] + '<br>';
|
||||||
|
html += 'Filter: ' + (currentRIR === 'all' ? 'All RIRs' : currentRIR.toUpperCase()) + ' | Networks without Atlas Probe: ' + filtered.length + '</div>';
|
||||||
|
|
||||||
|
Object.keys(byCountry).sort(function(a,b) { return byCountry[b].networks.length - byCountry[a].networks.length; }).forEach(function(cc) {
|
||||||
|
var c = byCountry[cc];
|
||||||
|
html += '<h2>' + c.name + ' (' + cc + ') — ' + c.networks.length + ' networks</h2>';
|
||||||
|
html += '<table><thead><tr><th>ASN</th><th>Name</th><th>Type</th><th>Status</th></tr></thead><tbody>';
|
||||||
|
c.networks.sort(function(a,b) { return a.asn - b.asn; }).forEach(function(n) {
|
||||||
|
html += '<tr><td>AS' + n.asn + '</td><td>' + (n.name || '') + '</td><td>' + (n.info_type || '-') + '</td><td class="no-probe">No Probe</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '<div class="meta" style="margin-top:3rem">Data from RIPE Atlas & PeeringDB | peercortex.org</div></body></html>';
|
||||||
|
w.document.write(html);
|
||||||
|
w.document.close();
|
||||||
|
w.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// File Upload: Parse ASNs/company names from uploaded files
|
||||||
|
// ============================================================
|
||||||
|
function handleFileUpload(input) {
|
||||||
|
var file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
var status = $('fileStatus');
|
||||||
|
status.textContent = 'Processing ' + file.name + '...';
|
||||||
|
status.style.color = 'var(--cyan)';
|
||||||
|
|
||||||
|
var ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (ext === 'csv' || ext === 'txt') {
|
||||||
|
file.text().then(function(text) { processFileText(text, file.name); });
|
||||||
|
} else if (ext === 'pdf' || ext === 'doc' || ext === 'docx' || ext === 'xls' || ext === 'xlsx') {
|
||||||
|
// For binary formats, upload to server for parsing
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
var base64 = e.target.result.split(',')[1];
|
||||||
|
fetch('/api/lia/parse-file', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: file.name, data: base64 })
|
||||||
|
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
if (d.error) {
|
||||||
|
status.textContent = 'Error: ' + d.error;
|
||||||
|
status.style.color = 'var(--red)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processFileText(d.text || '', file.name);
|
||||||
|
}).catch(function(err) {
|
||||||
|
// Fallback: try reading as text
|
||||||
|
file.text().then(function(text) { processFileText(text, file.name); }).catch(function() {
|
||||||
|
status.textContent = 'Cannot parse ' + ext.toUpperCase() + ' files client-side. Use CSV or TXT.';
|
||||||
|
status.style.color = 'var(--red)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Unsupported file type: ' + ext;
|
||||||
|
status.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFileText(text, filename) {
|
||||||
|
var status = $('fileStatus');
|
||||||
|
|
||||||
|
// Extract ASNs (AS12345 or just numbers that look like ASNs)
|
||||||
|
var asnMatches = text.match(/\bAS?(\d{3,7})\b/gi) || [];
|
||||||
|
var asns = new Set();
|
||||||
|
asnMatches.forEach(function(m) {
|
||||||
|
var num = parseInt(m.replace(/^AS/i, ''));
|
||||||
|
if (num >= 100 && num <= 9999999) asns.add(num);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also try to match company names against our loaded data
|
||||||
|
var nameMatches = [];
|
||||||
|
if (allData.length > 0) {
|
||||||
|
var lines = text.split(/[\n\r,;]+/).map(function(l) { return l.trim().toLowerCase(); }).filter(function(l) { return l.length > 2; });
|
||||||
|
lines.forEach(function(line) {
|
||||||
|
allData.forEach(function(n) {
|
||||||
|
if (n.name && n.name.toLowerCase().indexOf(line) >= 0 && line.length > 4) {
|
||||||
|
asns.add(n.asn);
|
||||||
|
}
|
||||||
|
if (line.indexOf(n.name ? n.name.toLowerCase() : '###') >= 0 && n.name && n.name.length > 4) {
|
||||||
|
asns.add(n.asn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asns.size === 0) {
|
||||||
|
status.textContent = 'No ASNs or matching networks found in ' + filename;
|
||||||
|
status.style.color = 'var(--orange)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match against our data
|
||||||
|
var results = [];
|
||||||
|
var asnArr = Array.from(asns);
|
||||||
|
var dataMap = {};
|
||||||
|
allData.forEach(function(n) { dataMap[n.asn] = n; });
|
||||||
|
|
||||||
|
asnArr.forEach(function(asn) {
|
||||||
|
var n = dataMap[asn];
|
||||||
|
results.push({
|
||||||
|
asn: asn,
|
||||||
|
name: n ? n.name : '(Unknown)',
|
||||||
|
country: n ? n.country : '',
|
||||||
|
info_type: n ? n.info_type : '',
|
||||||
|
has_probe: n ? n.has_probe : false,
|
||||||
|
in_peeringdb: !!n,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
results.sort(function(a, b) { return (a.has_probe ? 1 : 0) - (b.has_probe ? 1 : 0); });
|
||||||
|
|
||||||
|
var withProbe = results.filter(function(r) { return r.has_probe; }).length;
|
||||||
|
var noProbe = results.length - withProbe;
|
||||||
|
|
||||||
|
status.innerHTML = '<span style="color:var(--green)">' + withProbe + ' with probe</span> · <span style="color:var(--red)">' + noProbe + ' without probe</span> · ' + results.length + ' total from ' + escHtml(filename);
|
||||||
|
|
||||||
|
var h = '<div style="display:flex;gap:.5rem;margin-bottom:.75rem;flex-wrap:wrap">';
|
||||||
|
h += '<button class="export-btn" onclick="exportFileResults()" style="font-size:.7rem;padding:.3rem .8rem">Export Results as PDF</button>';
|
||||||
|
h += '</div>';
|
||||||
|
h += '<div class="asn-grid">';
|
||||||
|
results.forEach(function(r) {
|
||||||
|
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + r.asn + '\',\'_blank\')">';
|
||||||
|
h += '<span class="asn-num">AS' + r.asn + '</span>';
|
||||||
|
h += '<span class="asn-name">' + escHtml(r.name) + '</span>';
|
||||||
|
if (r.country) h += '<span style="font-size:.65rem;color:var(--dim)">' + flag(r.country) + ' ' + r.country + '</span>';
|
||||||
|
if (r.info_type) h += '<span class="asn-type">' + escHtml(r.info_type) + '</span>';
|
||||||
|
h += r.has_probe ? '<span class="has-probe">\u2714 Probe</span>' : '<span class="no-probe">\u2718 No Probe</span>';
|
||||||
|
if (!r.in_peeringdb) h += '<span style="font-size:.6rem;color:var(--dim)">(not in PeeringDB)</span>';
|
||||||
|
h += '</div>';
|
||||||
|
});
|
||||||
|
h += '</div>';
|
||||||
|
|
||||||
|
$('fileResults').innerHTML = h;
|
||||||
|
$('fileResults').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Store for export
|
||||||
|
window._fileResults = results;
|
||||||
|
window._fileName = filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFileResults() {
|
||||||
|
if (!window._fileResults) return;
|
||||||
|
var results = window._fileResults;
|
||||||
|
var w = window.open('', '_blank');
|
||||||
|
var html = '<!DOCTYPE html><html><head><title>Atlas Probe Check — ' + escHtml(window._fileName) + '</title>';
|
||||||
|
html += '<style>body{font-family:Arial,sans-serif;margin:2rem;color:#1a1a2e}h1{color:#5b21b6;font-size:1.3rem}table{width:100%;border-collapse:collapse;margin:1rem 0}th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #e2e8f0;font-size:.85rem}th{background:#f8fafc;font-weight:600}.yes{color:#16a34a;font-weight:700}.no{color:#dc2626;font-weight:700}.meta{color:#64748b;font-size:.8rem}</style></head><body>';
|
||||||
|
html += '<h1>Atlas Probe Coverage Check</h1>';
|
||||||
|
html += '<div class="meta">Source: ' + escHtml(window._fileName) + ' | Generated: ' + new Date().toISOString().split('T')[0] + ' | peercortex.org/lia</div>';
|
||||||
|
html += '<table><thead><tr><th>ASN</th><th>Name</th><th>Country</th><th>Type</th><th>Atlas Probe</th></tr></thead><tbody>';
|
||||||
|
results.forEach(function(r) {
|
||||||
|
html += '<tr><td>AS' + r.asn + '</td><td>' + escHtml(r.name) + '</td><td>' + (r.country || '-') + '</td><td>' + (r.info_type || '-') + '</td>';
|
||||||
|
html += '<td class="' + (r.has_probe ? 'yes' : 'no') + '">' + (r.has_probe ? 'YES' : 'NO') + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></body></html>';
|
||||||
|
w.document.write(html);
|
||||||
|
w.document.close();
|
||||||
|
w.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot
|
||||||
|
loadData();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
765
server.js
765
server.js
@ -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
2087
server.js.bak
Normal file
File diff suppressed because it is too large
Load Diff
2238
server.js.bak.20260327-003257
Normal file
2238
server.js.bak.20260327-003257
Normal file
File diff suppressed because it is too large
Load Diff
2087
server.js.bak.lia-1774526738149
Normal file
2087
server.js.bak.lia-1774526738149
Normal file
File diff suppressed because it is too large
Load Diff
2238
server.js.pre-lia-1774526865356
Normal file
2238
server.js.pre-lia-1774526865356
Normal file
File diff suppressed because it is too large
Load Diff
1
visitors.json
Normal file
1
visitors.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user