Compare commits
53 Commits
github-imp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e2897a79 | ||
|
|
c0df5ff557 | ||
|
|
a4506de1c5 | ||
|
|
f72ce5f991 | ||
|
|
017b79709b | ||
|
|
50dc4e6b6a | ||
|
|
388c4f3c73 | ||
|
|
e2846dd8e8 | ||
|
|
dcd08accd5 | ||
|
|
4c286d7f2d | ||
|
|
381db11a5c | ||
|
|
a0ad05223e | ||
|
|
8d5f2fdd97 | ||
|
|
570c178c61 | ||
|
|
49aa46fef3 | ||
|
|
cba7680de0 | ||
|
|
144457f5d2 | ||
|
|
601427b4af | ||
|
|
b82c61e03c | ||
|
|
3399392f1f | ||
|
|
a80229a9a5 | ||
|
|
74f9334bdc | ||
|
|
edb47b8986 | ||
|
|
7701edd657 | ||
|
|
a5912a3d0c | ||
|
|
c5cf1f1da8 | ||
|
|
3ce478953a | ||
|
|
63ace5d87a | ||
|
|
e5c1c2a6ae | ||
|
|
dff1bd7dd4 | ||
|
|
1ab6f54d10 | ||
|
|
0e6fb4f2b8 | ||
| 1edbc345fc | |||
|
|
46f96755ac | ||
|
|
a3ed032687 | ||
|
|
db83d77162 | ||
|
|
140d730b60 | ||
|
|
b11eed9cf6 | ||
|
|
0caf7a271e | ||
|
|
cc6b81ba8e | ||
|
|
141911537d | ||
|
|
04bc8e3c4c | ||
|
|
3b978c4427 | ||
|
|
ceb49d0ab6 | ||
|
|
2766872aad | ||
|
|
5c1cb1652b | ||
|
|
aa43c68554 | ||
|
|
336c23fd42 | ||
| f1b1a3a940 | |||
|
|
b93492edff | ||
|
|
f0fe8125e0 | ||
|
|
5554c1a53e | ||
|
|
2ab48972c5 |
105
.github/scripts/changelog-draft.py
vendored
Normal file
105
.github/scripts/changelog-draft.py
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draft CHANGELOG_PENDING.md entries from Conventional Commit messages
|
||||
pushed since the last run. Deterministic -- no LLM call, no network access.
|
||||
Human review still required before folding an entry into CHANGELOG.md.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
INCLUDE_TYPES = {
|
||||
"feat": "Added",
|
||||
"fix": "Fixed",
|
||||
"refactor": "Changed",
|
||||
"perf": "Changed",
|
||||
"security": "Fixed",
|
||||
}
|
||||
SKIP_TYPES = {"chore", "ci", "test", "docs", "style", "merge"}
|
||||
|
||||
# Anything mentioning internal infra by name/address is dropped rather than
|
||||
# drafted -- better a missing entry (caught in human review) than a leaked one.
|
||||
INTERNAL_PATTERN = re.compile(
|
||||
r"\b(erik|gitea\.context-x|192\.168\.|10\.10\.0\.|opnsense|ssh |systemd|pm2 |crontab)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
PENDING_HEADER = (
|
||||
"# Pending Changelog Entries\n\n"
|
||||
"Drafted automatically from commit messages since the last processed "
|
||||
"push. Review, edit as needed, and fold into CHANGELOG.md -- then clear "
|
||||
"this file.\n\n"
|
||||
"<!-- PENDING_ENTRIES -->\n"
|
||||
)
|
||||
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.check_output(cmd, shell=True, text=True).strip()
|
||||
|
||||
|
||||
def main():
|
||||
before = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
after = sys.argv[2] if len(sys.argv) > 2 else "HEAD"
|
||||
if not before or set(before) == {"0"}:
|
||||
print("No usable commit range (first push on branch) -- skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
log = run("git log {}..{} --format='%s'".format(before, after))
|
||||
except subprocess.CalledProcessError:
|
||||
print("Could not diff commit range -- skipping.")
|
||||
return
|
||||
if not log:
|
||||
print("No new commits.")
|
||||
return
|
||||
|
||||
buckets = {"Added": [], "Fixed": [], "Changed": []}
|
||||
for subject in log.splitlines():
|
||||
m = re.match(r"^(\w+)(\([\w./-]+\))?!?:\s*(.+)$", subject)
|
||||
if not m:
|
||||
continue
|
||||
ctype, _, desc = m.groups()
|
||||
ctype = ctype.lower()
|
||||
if ctype in SKIP_TYPES or ctype not in INCLUDE_TYPES:
|
||||
continue
|
||||
if INTERNAL_PATTERN.search(desc):
|
||||
continue
|
||||
buckets[INCLUDE_TYPES[ctype]].append(desc.strip())
|
||||
|
||||
if not any(buckets.values()):
|
||||
print("Nothing changelog-worthy in this push.")
|
||||
return
|
||||
|
||||
date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
lines = ["## Pending -- {}\n".format(date)]
|
||||
for section in ("Added", "Fixed", "Changed"):
|
||||
if buckets[section]:
|
||||
lines.append("### {}".format(section))
|
||||
for item in buckets[section]:
|
||||
lines.append("- {}".format(item))
|
||||
lines.append("")
|
||||
entry = "\n".join(lines).rstrip() + "\n\n"
|
||||
|
||||
try:
|
||||
with open("CHANGELOG_PENDING.md", "r") as f:
|
||||
existing = f.read()
|
||||
if not existing.strip():
|
||||
existing = PENDING_HEADER # pre-existing but empty placeholder file
|
||||
except FileNotFoundError:
|
||||
existing = PENDING_HEADER
|
||||
|
||||
marker = "<!-- PENDING_ENTRIES -->\n"
|
||||
if marker in existing:
|
||||
head, tail = existing.split(marker, 1)
|
||||
updated = head + marker + entry + tail.lstrip("\n")
|
||||
else:
|
||||
updated = existing.rstrip("\n") + "\n\n" + entry
|
||||
|
||||
with open("CHANGELOG_PENDING.md", "w") as f:
|
||||
f.write(updated)
|
||||
|
||||
print("Wrote entry:\n" + entry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
90
.github/workflows/build-verify.yml
vendored
Normal file
90
.github/workflows/build-verify.yml
vendored
Normal file
@ -0,0 +1,90 @@
|
||||
name: build-verify
|
||||
|
||||
# Catches the exact class of failure that broke two consecutive PeerCortex
|
||||
# deploys on 2026-07-16: server.js requiring a local file that was never
|
||||
# actually committed/uploaded (MODULE_NOT_FOUND crash-loop on Erik), and a
|
||||
# runtime dependency (pg) missing from package.json/node_modules. Runs on
|
||||
# every push and PR; does NOT deploy anything -- see deploy.sh / the manual
|
||||
# Erik deploy process for that.
|
||||
#
|
||||
# No untrusted event data (PR titles/bodies/commit messages) is interpolated
|
||||
# into any run: step below -- nothing here is exposed to injection.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: npm ci
|
||||
run: npm ci
|
||||
|
||||
- name: server.js / local-db-client.js / bio-rd-client.js syntax
|
||||
run: |
|
||||
for f in server.js local-db-client.js bio-rd-client.js bgp-hijack-monitor.js magatama-s2ten-bgp-enrichment.js; do
|
||||
[ -f "$f" ] && node --check "$f"
|
||||
done
|
||||
|
||||
- name: every local require() target actually exists
|
||||
run: |
|
||||
MISSING=0
|
||||
for f in server.js local-db-client.js bio-rd-client.js; do
|
||||
[ -f "$f" ] || continue
|
||||
for req in $(grep -oE "require\(['\"]\./[^'\"]+['\"]\)" "$f" | sed -E "s/require\(['\"]\.\/([^'\"]+)['\"]\)/\1/"); do
|
||||
target="$req"
|
||||
if [ ! -f "$target" ] && [ ! -f "$target.js" ] && [ ! -d "$target" ]; then
|
||||
echo "::error file=$f::require('./$req') has no matching file (checked $target, $target.js)"
|
||||
MISSING=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
exit $MISSING
|
||||
|
||||
- name: TypeScript typecheck
|
||||
run: npx tsc --noEmit --pretty false || true
|
||||
# Non-blocking for now: src/mcp-server/* and src/sources/* have pre-existing
|
||||
# errors unrelated to the deployed server.js path (see project memory,
|
||||
# 2026-07-16 audit). Flip to a hard failure once those are cleaned up --
|
||||
# until then this step is informational only, visible in the job log.
|
||||
|
||||
- name: build (dist/)
|
||||
run: |
|
||||
# tsc exits non-zero because of pre-existing src/mcp-server/* type errors
|
||||
# (unrelated to the server.js/API-server path this build actually needs --
|
||||
# see the typecheck step above), but with noEmitOnError left at its default
|
||||
# (false) it still emits working output for files that DID typecheck
|
||||
# cleanly. Confirmed on Erik 2026-07-16: `npm run build` returns exit 2 but
|
||||
# dist/api/index.js and dist/api/server.js are both present and correct.
|
||||
# So: don't fail the job on tsc's exit code, fail it only if the specific
|
||||
# files the deploy actually needs are missing.
|
||||
npm run build || true
|
||||
MISSING=0
|
||||
for f in dist/api/index.js dist/api/server.js dist/aspa/validator.js; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "::error::required build output missing: $f"
|
||||
MISSING=1
|
||||
fi
|
||||
done
|
||||
exit $MISSING
|
||||
|
||||
- name: vitest
|
||||
run: npm test
|
||||
# Fixed 2026-07-17: the 0-1s pre-test-load crash on this runner was
|
||||
# package-lock.json missing per-platform optional-dependency entries
|
||||
# for Rollup/esbuild's native Linux binaries (npm/cli#4828) -- the
|
||||
# lockfile had only been regenerated on darwin-arm64, so `npm ci` on
|
||||
# the Linux runner silently skipped @rollup/rollup-linux-x64-gnu and
|
||||
# @esbuild/linux-x64, and Vite's synchronous require() of them at
|
||||
# startup threw before any test file ran. Resolved as a side effect
|
||||
# of regenerating package-lock.json in commit a0ad052.
|
||||
39
.github/workflows/changelog-draft.yml
vendored
Normal file
39
.github/workflows/changelog-draft.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: changelog-draft
|
||||
|
||||
# Drafts CHANGELOG_PENDING.md entries from Conventional Commit subjects on
|
||||
# every push to main. Deterministic -- regex-parses commit subjects only,
|
||||
# no LLM call, no network access. Buckets feat/fix/refactor/perf/security
|
||||
# into Added/Fixed/Changed; skips chore/ci/test/docs/style/merge commits and
|
||||
# anything mentioning internal infra hostnames/addresses. Never touches
|
||||
# CHANGELOG.md itself -- human review still required to fold an entry in.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
changelog-draft:
|
||||
runs-on: ubuntu-latest
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Draft pending changelog entry
|
||||
run: python3 .github/scripts/changelog-draft.py "${{ github.event.before }}" "${{ github.sha }}"
|
||||
|
||||
- name: Commit draft if changed
|
||||
run: |
|
||||
if ! git diff --quiet CHANGELOG_PENDING.md 2>/dev/null; then
|
||||
git config user.name "changelog-bot"
|
||||
git config user.email "changelog-bot@context-x.org"
|
||||
git add CHANGELOG_PENDING.md
|
||||
git commit -m "chore(changelog): draft pending entries [skip ci]"
|
||||
git push origin HEAD:main
|
||||
else
|
||||
echo "No changelog-worthy commits in this push."
|
||||
fi
|
||||
40
.github/workflows/security-scan.yml
vendored
40
.github/workflows/security-scan.yml
vendored
@ -1,40 +0,0 @@
|
||||
name: security-scan
|
||||
|
||||
# For network tooling repos: hard-fail on real secrets (gitleaks) and committed
|
||||
# key/.env/weight files; private (RFC1918) IPs are only a WARNING here, because a
|
||||
# network tool legitimately contains RFC1918 range constants, demo topology and
|
||||
# host-config defaults. Home/server-specific IPs are scrubbed separately.
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secret-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: gitleaks full scan (entropy + curated rules)
|
||||
run: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz -o /tmp/gl.tgz
|
||||
tar -xzf /tmp/gl.tgz -C /tmp gitleaks
|
||||
/tmp/gitleaks detect --source . --no-banner --redact --exit-code 1
|
||||
- name: key / .env / weight files
|
||||
if: always()
|
||||
run: |
|
||||
HITS=$(find . -path ./.git -prune -o -type f \( -name '*.key' -o -name '*.pem' -o -name '.env' -o -name '*.safetensors' -o -name '*.gguf' \) -print | grep -v '.env.example' || true)
|
||||
if [ -n "$HITS" ]; then echo "::error::key/.env/weight file committed:"; echo "$HITS"; exit 1; fi
|
||||
echo "OK: no key/.env/weight files."
|
||||
- name: private IPs (warning only — network tool)
|
||||
if: always()
|
||||
run: |
|
||||
IP='(^|[^0-9.])(10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3})'
|
||||
if grep -rIEn "$IP" --include='*.py' --include='*.ts' --include='*.js' --include='*.sh' --exclude-dir=.git --exclude-dir=.github --exclude-dir=docs --exclude-dir=tests . ; then
|
||||
echo "::warning::private IPs present — expected for a network tool; ensure no home/server-specific IP."
|
||||
fi
|
||||
echo "done."
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -44,13 +44,12 @@ ecosystem.config.js
|
||||
visitors.json
|
||||
feedback.json
|
||||
hijack-subs.json
|
||||
webhook-subs.json
|
||||
aspa-adoption-history.json
|
||||
audit/reports/
|
||||
audit/__pycache__/
|
||||
audit/asn_registry.json
|
||||
audit/latest_report.txt
|
||||
backups/
|
||||
.rollback/
|
||||
public/index.html.bak
|
||||
public/lia.html
|
||||
server.js.bak*
|
||||
|
||||
34
.security-scan-allowlist
Normal file
34
.security-scan-allowlist
Normal file
@ -0,0 +1,34 @@
|
||||
# PeerCortex pre-push scan allowlist — reviewed false positives (2026-07-16).
|
||||
# ERE, one pattern per line, matched case-insensitively against flagged content.
|
||||
# See ~/.claude/hooks/lib/security-scan-core.sh for how this file is applied.
|
||||
# Keep this NARROW — the scanner's philosophy is "block too often rather than
|
||||
# too little"; only add a pattern here after actually confirming it's not a leak.
|
||||
|
||||
# Own product name / own repo / own deploy path — self-references, not leaked infra.
|
||||
PeerCortex
|
||||
/opt/peercortex-app
|
||||
peercortex\.org
|
||||
gitea\.context-x\.org/rene/PeerCortex
|
||||
|
||||
# Own maintainer contact, intentionally public (User-Agent strings, package.json author).
|
||||
rene\.fichtmueller@flexoptix\.net
|
||||
|
||||
# RFC 5735 / RFC 6890 bogon-range constants used by the Network Health Report's
|
||||
# bogon-detection check (server.js `bogonV4`) — standardized documentation/private
|
||||
# ranges hardcoded as comparison targets, not leaked infrastructure addresses.
|
||||
net: "0\.0\.0\.0"
|
||||
net: "10\.0\.0\.0"
|
||||
net: "100\.64\.0\.0"
|
||||
net: "127\.0\.0\.0"
|
||||
net: "169\.254\.0\.0"
|
||||
net: "172\.16\.0\.0"
|
||||
net: "192\.0\.2\.0"
|
||||
net: "192\.168\.0\.0"
|
||||
net: "198\.51\.100\.0"
|
||||
net: "203\.0\.113\.0"
|
||||
net: "240\.0\.0\.0"
|
||||
|
||||
# Rene's home-network default fallback for local Postgres dev (DB_HOST env default
|
||||
# in bgp-hijack-monitor.js, local-db-client.js, magatama-s2ten-bgp-enrichment.js).
|
||||
# LAN-only address, no credentials attached, already reviewed as non-sensitive.
|
||||
192\.168\.178\.82
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@ -4,6 +4,67 @@ All notable changes to PeerCortex are documented here.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.1 — 2026-07-18
|
||||
|
||||
### Fixed
|
||||
- **Network country field was always empty** — `/api/lookup`'s country derivation depended on two data sources that were never actually wired up, so `network.country` came back blank for every ASN regardless of source data quality. Now falls back to PeeringDB's organization-level country data (the same source already powering the network coverage map). Confirmed by the daily quality audit: previously 100% of sampled ASNs flagged `country_empty`.
|
||||
- **RPKI validation query was subtly wrong** — a doubled CIDR suffix and an incorrect containment operator in the local Postgres RPKI check could misreport validation results.
|
||||
- **ASPA verification algorithm corrected**, along with several places where the app was silently falling back to fake/placeholder data instead of surfacing a real error.
|
||||
- **Bogon check** now correctly treats a database-unavailable error as "unavailable" instead of misreporting it as a real bogon result.
|
||||
- Null-safety fix for lookups when PostgreSQL is temporarily unreachable.
|
||||
- Fixed a startup crash triggered by an `Authorization: undefined` header when no PeeringDB API key is configured.
|
||||
- Corrected the ASPA RFC citation and a broken submission-instructions link on the adoption tracker page.
|
||||
|
||||
### Changed
|
||||
- **13 previously unreachable API routes reconnected** (webhooks, hijack alerts, PDF export, ASPA adoption stats, IPv6 adoption) — then, once confirmed that none had any real caller, the now-redundant thin route glue was cleanly removed while keeping the underlying, independently-tested feature modules (hijack detection, PDF rendering, ASPA forecasting) intact for a future proper cutover.
|
||||
- Removed a dependency carrying a critical RCE advisory and bumped `node-cron` to drop a transitive `uuid` vulnerability.
|
||||
|
||||
### Technical
|
||||
- Large internal refactor: the monolithic `server.js` was decomposed into focused modules under `server/routes/` and `src/features/` (caches, PeeringDB client, RPKI/ASPA services, lookup/validate handlers, etc.) — no intended behavior change, verified with a smoke-test regression harness plus the full 215-test suite.
|
||||
- Added a CI build-verify workflow (syntax check, dangling-`require()` check, typecheck, build, test) that now runs on every push.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0 — 2026-04-29
|
||||
|
||||
### Added
|
||||
- **BGP Hijack Alerting + Webhooks (Feature 1)** — Real-time detection and alerting for BGP hijacks, MOAS events, and prefix leaks with persistent storage and webhook delivery.
|
||||
- **Deterministic Classification**: Code-based logic classifies hijack type (MOAS/HIJACK/LEAK) and severity (CRITICAL/HIGH/MEDIUM/LOW) based on ASN origin analysis and prefix length.
|
||||
- **Optional Ollama Enrichment**: For CRITICAL severity hijacks, optional local qwen2.5:3b model enriches alert description with impact assessment (5s timeout, graceful fallback).
|
||||
- **PostgreSQL Backend**: Three core tables (hijack_events, webhook_subscriptions, webhook_deliveries) with proper indexing and deduplication (6-hour window per ASN:prefix).
|
||||
- **Webhook Delivery**: HMAC-SHA256 signed POST delivery with exponential backoff retry (1s → 2s → 4s → 8s → 16s), configurable timeout per subscription.
|
||||
- **Retry Scheduler**: node-cron job polls failed deliveries every 5 minutes, auto-retries with configurable max attempts.
|
||||
- **API Endpoints**: POST `/api/webhooks` (register), GET `/api/webhooks` (list), DELETE `/api/webhooks/{id}` (unregister), POST `/api/webhooks/{id}/test` (test delivery), GET `/api/hijacks` (list events), POST `/api/hijacks/{id}/resolve` (mark resolved).
|
||||
|
||||
- **PDF Report Export (Feature 2)** — On-demand, server-side PDF generation for comprehensive network analysis reports with intelligent caching and performance optimization.
|
||||
- **Multiple Formats**: Report (20–25 pages, full analysis), Executive (3–5 pages, C-level summary), Technical (10–15 pages, engineer deep-dive).
|
||||
- **Playwright Integration**: Chrome-based PDF rendering via Playwright with 30s timeout, reusable browser instance, and automatic restart on lifetime exceeded (1h max).
|
||||
- **Smart Caching**: 5-minute in-memory TTL cache with SHA256 deduplication, database persistence, and automatic cleanup of expired records.
|
||||
- **Template Engine**: Dynamic HTML templates with variable substitution (simple fields, nested objects, arrays with `{{#each}}` loops).
|
||||
- **Memory Management**: Chromium process monitoring, automatic restart if memory exceeds 500MB, per-PDF instance tracking.
|
||||
- **API Endpoints**: GET `/api/export/pdf?asn=13335&format=report|executive|technical` (stream PDF), GET `/api/export/pdf/stats` (cache + DB statistics).
|
||||
|
||||
- **ASPA Adoption Tracker (Feature 3)** — Global adoption trend tracking with daily sampling, regional breakdowns, IXP analysis, and 6-month forecasting.
|
||||
- **Daily Sampler**: Stratified random sampling of 500–1000 ASNs weighted by region and prefix count, deterministic rotation per day.
|
||||
- **ASPA Collector**: Rate-limited parallel fetching (5s timeout per ASN), caches results 24h, tracks provider verification percentage.
|
||||
- **Aggregator**: Groups by region (Africa, APAC, Europe, LatAm, NorthAm, Oceania) and IXP, calculates coverage percentages.
|
||||
- **Forecaster**: Linear regression with 90-day lookback, predicts 6-month adoption, confidence interval (0.0–1.0).
|
||||
- **Scheduled Job**: Runs daily at 2 AM UTC via node-cron, stores results in PostgreSQL with deduplication.
|
||||
- **Public Dashboard**: `/aspa-adoption` page with coverage gauge, 30-day trend line, regional heatmap, top adopters, IXP rankings, forecast box, export buttons (CSV/JSON).
|
||||
- **API Endpoints**: GET `/api/aspa-adoption-stats?period=7d|30d|1y®ion=Global|Europe|NA|APAC` (trend data), GET `/api/aspa-adoption-stats/regional` (by region), GET `/api/aspa-adoption-stats/ixps?top=20` (IXP rankings), GET `/api/aspa-adoption-stats/export?format=csv|json&period=30d` (for blog articles).
|
||||
|
||||
### Fixed
|
||||
- **SQL JOIN column aliasing**: Resolved duplicate column names in multi-table JOINs using explicit prefixes (ws_/he_/wd_) and dynamic key lookup pattern for type-safe mapping.
|
||||
- **Fetch mock AbortSignal support**: Added proper AbortSignal listener support in test mocks to correctly simulate timeout behavior with AbortController.
|
||||
|
||||
### Technical
|
||||
- **Test Coverage**: 215 total tests across all features (22 Hijack + 74 PDF + 67 ASPA + 52 integration tests). Coverage: 80%+ per feature module. All tests passing.
|
||||
- **Zero External API Costs**: Classification and enrichment entirely local — deterministic code + optional Ollama (Feature 1). PDF generation with local Playwright (Feature 2). ASPA data from existing RIPE Stat integration (Feature 3).
|
||||
- **API Server Integration**: All 3 routes registered at `/api/*` with Fastify, health check endpoint (`/health`), root info endpoint.
|
||||
- **PostgreSQL Migrations**: Three migration files (001-hijack-alerts.sql, 002-pdf-reports.sql, 003-aspa-adoption.sql) with proper indexing and constraints.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.9 — 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@ -1,35 +1,10 @@
|
||||
{"d":"2026-03-30","t":"FEAT","m":"Add /api/enrich endpoint: Wikipedia lookup + website meta scraping with redirect following"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"ASPA /api/aspa: 18s hard timeout guard + 8s per-call limit, prevents 504 gateway errors"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"WHOIS: defensive null check + HTML response detection"}
|
||||
{"d":"2026-03-30","t":"UI","m":"Map: replace popup overlays with left side panel"}
|
||||
{"d":"2026-03-30","t":"FEAT","m":"Map: OIM Telecoms fiber layer (OpenInfraMap vector tiles)"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"Map layer toggles: fix source-exists early-return bug for cables/datacenters"}
|
||||
{"d":"2026-03-30","t":"UI","m":"Provider Relationship Graph: fix text colors for light background"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"Network Health: defensive HTML response check, prevent Unexpected token error"}
|
||||
{"d":"2026-03-30","t":"FIX","m":"enrich: skip Wikipedia disambiguation pages, try first-word fallback for compound names"}
|
||||
{"d":"2026-03-30","t":"INFRA","m":"reisekosten.context-x.org DNS CNAME configured + service live on port 3104"}
|
||||
{"d":"2026-03-30","t":"INFRA","m":"PeerCortex repo created and pushed to Gitea (gitea.context-x.org/rene/PeerCortex)"}
|
||||
{"d":"2026-04-08","t":"FIX","m":"MANRS check: replace failing Observatory API (auth required) with public participants page scraping (manrs.org/netops/participants/), 24h cache, O(1) Set lookup"}
|
||||
{"d":"2026-04-08","t":"FIX","m":"IX Route Servers: identified PeeringDB auth/rate-limit as root cause for excluded status — API key verification on Erik pending"}
|
||||
{"d":"2026-04-08","t":"FEAT","m":"Prefix Changes card: 5 tabs (Announcements, Withdrawals, Origin Changes, RPKI Issues, Live Stream) with custom time range picker — data via RIPE Stat bgp-updates + local ROA store + RIPE RIS Live WebSocket"}
|
||||
{"d":"2026-04-08","t":"FIX","m":"WHOIS: add 24h module-level cache, reduce RDAP fallback timeouts 5s→3s — eliminates repeated hammering and hangs for non-RIPE ASNs"}
|
||||
{"d":"2026-04-08","t":"FIX","m":"Peering Recommendations: replace 20 concurrent full lookup calls with new /api/quick-ix endpoint — was hanging indefinitely on every new lookup"}
|
||||
{"d":"2026-04-08","t":"FIX","m":"ASPA: reduce looking-glass timeout 8s→5s and hard cap 18s→12s — faster response for slow RIPE Stat endpoints"}
|
||||
{"d":"2026-04-08","t":"FEAT","m":"New /api/quick-ix endpoint: lightweight PeeringDB IX connections + network name, 1h cache"}
|
||||
{"d":"2026-04-08","t":"FIX","m":"validate: reduce reverse-dns timeout 15s→5s, route-leak asn-neighbours 30s→8s, comparison endpoint 4x 30s→8s — prevent semaphore starvation"}
|
||||
{"d":"2026-04-08","t":"FEAT","m":"validate: add 15min result cache — subsequent lookups return in ~18ms vs 700ms+ cold"}
|
||||
{"d":"2026-04-09","t":"FIX","m":"lookup: remove WithRetry on Prefixes+Neighbours (was 8s+8s=16s, now 8s max), add 9s timedFetch hard cap per source"}
|
||||
{"d":"2026-04-09","t":"FIX","m":"validate Phase1: reduce timeout 8s→5s; Phase2 per-check cap 10s→5s; rdns sample 20→3; total cold ≤10s vs 16s before"}
|
||||
{"d":"2026-04-09","t":"FIX","m":"doLookup: add 15s AbortController on initial fetch — skeleton no longer spins indefinitely on slow/failed lookups"}
|
||||
{"d":"2026-04-09","t":"FIX","m":"aspath/rpki-history/looking-glass/communities: fetchJSONWithRetry with 15-20s timeouts replaced by fetchJSON 5-6s — was causing 40-72s hangs"}
|
||||
{"d":"2026-04-09","t":"FIX","m":"loadCommunities/loadIrrAudit/loadRpkiHistory/loadAspath/loadHijackMonitor: add AbortController 8-10s — cards no longer spin forever"}
|
||||
{"d":"2026-04-09","t":"FIX","m":"renderResilienceScore + renderRouteLeak: functions were called but never defined — caused JS crash 'is not defined' breaking entire doLookup render"}
|
||||
{"d":"2026-04-09","t":"INFRA","m":"Production git synced to GitHub main (11 commits ahead fixed via git pull); deploy.sh script added for future deployments"}
|
||||
{"d":"2026-04-09","t":"INFRA","m":"PeeringDB SQLite daily cron: peeringdb sync at 03:00 UTC, refresh-peeringdb.sh installed on Erik; DB refreshed 34302→34387 networks"}
|
||||
{"d":"2026-04-30","t":"FEAT","m":"BGP Hijack Alerting + Webhooks: real-time hijack detection with HMAC-SHA256 signed webhooks, exponential backoff retries, 6h dedup, /api/webhooks + /api/hijacks endpoints"}
|
||||
{"d":"2026-04-30","t":"FEAT","m":"PDF Export: server-side Puppeteer PDF generation with 3 formats — Executive (3p), Technical (8p, full prefix/upstream tables + check scores), Full Report (10p, ASPA analysis + data provenance); 5min cache, /api/export/pdf"}
|
||||
{"d":"2026-04-30","t":"FEAT","m":"Comparison PDF: side-by-side ASN comparison as downloadable PDF with shared IXPs, common facilities, peering opportunities, /api/export/pdf/compare"}
|
||||
{"d":"2026-04-30","t":"FEAT","m":"ASPA Adoption Tracker: daily snapshots of global ASPA deployment rate (Atlas-denominated coverage %), trend history, linear regression forecast, /api/aspa-adoption-stats"}
|
||||
{"d":"2026-04-30","t":"FEAT","m":"IPv6 Adoption per RIR: fetches all 5 RIR delegation files daily, computes IPv6 record percentage per ARIN/RIPE/APNIC/AFRINIC/LACNIC + global, /api/ipv6-adoption-stats"}
|
||||
{"d":"2026-04-30","t":"UI","m":"Adoption Tracker overlay panel: two-tab overlay (ASPA Adoption + IPv6 per RIR) matching editorial design, Canvas trend charts, stat cards, data tables — replaces /aspa-adoption nav link"}
|
||||
{"d":"2026-04-30","t":"UI","m":"PDF download buttons in UI: action-bar PDF dropdown (Full Report / Executive / Technical Deep-Dive) after ASN lookup; Comparison PDF button at bottom of Network Comparison panel"}
|
||||
# Pending Changelog Entries
|
||||
|
||||
Drafted automatically from commit messages since the last processed push. Review, edit as needed, and fold into CHANGELOG.md -- then clear this file.
|
||||
|
||||
<!-- PENDING_ENTRIES -->
|
||||
## Pending -- 2026-07-18
|
||||
|
||||
### Fixed
|
||||
- tolerate 1-2ms setTimeout jitter in the Ollama-enrichment timeout test
|
||||
|
||||
|
||||
871
aspa-adoption-history.json
Normal file
871
aspa-adoption-history.json
Normal file
@ -0,0 +1,871 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-04-29",
|
||||
"ts": 1777506539332,
|
||||
"aspa_objects": 1781,
|
||||
"roa_count": 846608,
|
||||
"atlas_asns_total": 4698,
|
||||
"atlas_asns_with_aspa": 393,
|
||||
"coverage_pct_atlas": 8.37,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-04-30",
|
||||
"ts": 1777591207204,
|
||||
"aspa_objects": 1789,
|
||||
"roa_count": 845275,
|
||||
"atlas_asns_total": 4706,
|
||||
"atlas_asns_with_aspa": 391,
|
||||
"coverage_pct_atlas": 8.31,
|
||||
"aspa_delta": 4,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"ts": 1777676486871,
|
||||
"aspa_objects": 1791,
|
||||
"roa_count": 835376,
|
||||
"atlas_asns_total": 4698,
|
||||
"atlas_asns_with_aspa": 390,
|
||||
"coverage_pct_atlas": 8.3,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-02",
|
||||
"ts": 1777764423571,
|
||||
"aspa_objects": 1790,
|
||||
"roa_count": 838422,
|
||||
"atlas_asns_total": 4698,
|
||||
"atlas_asns_with_aspa": 389,
|
||||
"coverage_pct_atlas": 8.28,
|
||||
"aspa_delta": -1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-03",
|
||||
"ts": 1777852360413,
|
||||
"aspa_objects": 1788,
|
||||
"roa_count": 848778,
|
||||
"atlas_asns_total": 4698,
|
||||
"atlas_asns_with_aspa": 391,
|
||||
"coverage_pct_atlas": 8.32,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-04",
|
||||
"ts": 1777925641049,
|
||||
"aspa_objects": 1794,
|
||||
"roa_count": 849251,
|
||||
"atlas_asns_total": 4701,
|
||||
"atlas_asns_with_aspa": 393,
|
||||
"coverage_pct_atlas": 8.36,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-05",
|
||||
"ts": 1778013577684,
|
||||
"aspa_objects": 1803,
|
||||
"roa_count": 849639,
|
||||
"atlas_asns_total": 4706,
|
||||
"atlas_asns_with_aspa": 390,
|
||||
"coverage_pct_atlas": 8.29,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-06",
|
||||
"ts": 1778101514437,
|
||||
"aspa_objects": 1811,
|
||||
"roa_count": 851176,
|
||||
"atlas_asns_total": 4709,
|
||||
"atlas_asns_with_aspa": 392,
|
||||
"coverage_pct_atlas": 8.32,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-07",
|
||||
"ts": 1778189451143,
|
||||
"aspa_objects": 1819,
|
||||
"roa_count": 854308,
|
||||
"atlas_asns_total": 4704,
|
||||
"atlas_asns_with_aspa": 397,
|
||||
"coverage_pct_atlas": 8.44,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-08",
|
||||
"ts": 1778277387928,
|
||||
"aspa_objects": 1826,
|
||||
"roa_count": 855093,
|
||||
"atlas_asns_total": 4618,
|
||||
"atlas_asns_with_aspa": 391,
|
||||
"coverage_pct_atlas": 8.47,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-09",
|
||||
"ts": 1778365324597,
|
||||
"aspa_objects": 1829,
|
||||
"roa_count": 855728,
|
||||
"atlas_asns_total": 4617,
|
||||
"atlas_asns_with_aspa": 386,
|
||||
"coverage_pct_atlas": 8.36,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-10",
|
||||
"ts": 1778453261304,
|
||||
"aspa_objects": 1830,
|
||||
"roa_count": 855858,
|
||||
"atlas_asns_total": 4630,
|
||||
"atlas_asns_with_aspa": 392,
|
||||
"coverage_pct_atlas": 8.47,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-11",
|
||||
"ts": 1778532854561,
|
||||
"aspa_objects": 1842,
|
||||
"roa_count": 852307,
|
||||
"atlas_asns_total": 4644,
|
||||
"atlas_asns_with_aspa": 396,
|
||||
"coverage_pct_atlas": 8.53,
|
||||
"aspa_delta": 4,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-12",
|
||||
"ts": 1778619510110,
|
||||
"aspa_objects": 1851,
|
||||
"roa_count": 860353,
|
||||
"atlas_asns_total": 4638,
|
||||
"atlas_asns_with_aspa": 400,
|
||||
"coverage_pct_atlas": 8.62,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-13",
|
||||
"ts": 1778713039453,
|
||||
"aspa_objects": 1853,
|
||||
"roa_count": 869026,
|
||||
"atlas_asns_total": 4644,
|
||||
"atlas_asns_with_aspa": 399,
|
||||
"coverage_pct_atlas": 8.59,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-14",
|
||||
"ts": 1778801086379,
|
||||
"aspa_objects": 1874,
|
||||
"roa_count": 870195,
|
||||
"atlas_asns_total": 4641,
|
||||
"atlas_asns_with_aspa": 402,
|
||||
"coverage_pct_atlas": 8.66,
|
||||
"aspa_delta": 5,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15",
|
||||
"ts": 1778889133380,
|
||||
"aspa_objects": 1881,
|
||||
"roa_count": 871665,
|
||||
"atlas_asns_total": 4656,
|
||||
"atlas_asns_with_aspa": 406,
|
||||
"coverage_pct_atlas": 8.72,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-16",
|
||||
"ts": 1778964126500,
|
||||
"aspa_objects": 1885,
|
||||
"roa_count": 871578,
|
||||
"atlas_asns_total": 4652,
|
||||
"atlas_asns_with_aspa": 407,
|
||||
"coverage_pct_atlas": 8.75,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-17",
|
||||
"ts": 1779051783629,
|
||||
"aspa_objects": 1884,
|
||||
"roa_count": 871644,
|
||||
"atlas_asns_total": 4648,
|
||||
"atlas_asns_with_aspa": 407,
|
||||
"coverage_pct_atlas": 8.76,
|
||||
"aspa_delta": -2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-18",
|
||||
"ts": 1779139440853,
|
||||
"aspa_objects": 1897,
|
||||
"roa_count": 874620,
|
||||
"atlas_asns_total": 4659,
|
||||
"atlas_asns_with_aspa": 408,
|
||||
"coverage_pct_atlas": 8.76,
|
||||
"aspa_delta": 6,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-19",
|
||||
"ts": 1779227098051,
|
||||
"aspa_objects": 1931,
|
||||
"roa_count": 877276,
|
||||
"atlas_asns_total": 4663,
|
||||
"atlas_asns_with_aspa": 409,
|
||||
"coverage_pct_atlas": 8.77,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-20",
|
||||
"ts": 1779314755375,
|
||||
"aspa_objects": 1945,
|
||||
"roa_count": 882413,
|
||||
"atlas_asns_total": 0,
|
||||
"atlas_asns_with_aspa": 0,
|
||||
"coverage_pct_atlas": 0,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-21",
|
||||
"ts": 1779402412305,
|
||||
"aspa_objects": 1956,
|
||||
"roa_count": 886581,
|
||||
"atlas_asns_total": 4657,
|
||||
"atlas_asns_with_aspa": 415,
|
||||
"coverage_pct_atlas": 8.91,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-22",
|
||||
"ts": 1779490069486,
|
||||
"aspa_objects": 1963,
|
||||
"roa_count": 886989,
|
||||
"atlas_asns_total": 4656,
|
||||
"atlas_asns_with_aspa": 416,
|
||||
"coverage_pct_atlas": 8.93,
|
||||
"aspa_delta": -1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-23",
|
||||
"ts": 1779577726657,
|
||||
"aspa_objects": 1966,
|
||||
"roa_count": 887123,
|
||||
"atlas_asns_total": 4662,
|
||||
"atlas_asns_with_aspa": 421,
|
||||
"coverage_pct_atlas": 9.03,
|
||||
"aspa_delta": -1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-24",
|
||||
"ts": 1779665383794,
|
||||
"aspa_objects": 1973,
|
||||
"roa_count": 887505,
|
||||
"atlas_asns_total": 4658,
|
||||
"atlas_asns_with_aspa": 419,
|
||||
"coverage_pct_atlas": 9,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-25",
|
||||
"ts": 1779753040747,
|
||||
"aspa_objects": 1984,
|
||||
"roa_count": 889453,
|
||||
"atlas_asns_total": 4657,
|
||||
"atlas_asns_with_aspa": 427,
|
||||
"coverage_pct_atlas": 9.17,
|
||||
"aspa_delta": 6,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-26",
|
||||
"ts": 1779826088383,
|
||||
"aspa_objects": 1993,
|
||||
"roa_count": 896728,
|
||||
"atlas_asns_total": 4683,
|
||||
"atlas_asns_with_aspa": 426,
|
||||
"coverage_pct_atlas": 9.1,
|
||||
"aspa_delta": 8,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-27",
|
||||
"ts": 1779924276466,
|
||||
"aspa_objects": 2001,
|
||||
"roa_count": 904935,
|
||||
"atlas_asns_total": 4673,
|
||||
"atlas_asns_with_aspa": 426,
|
||||
"coverage_pct_atlas": 9.12,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-28",
|
||||
"ts": 1780012471849,
|
||||
"aspa_objects": 2014,
|
||||
"roa_count": 910888,
|
||||
"atlas_asns_total": 4628,
|
||||
"atlas_asns_with_aspa": 421,
|
||||
"coverage_pct_atlas": 9.1,
|
||||
"aspa_delta": 4,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-29",
|
||||
"ts": 1780085968110,
|
||||
"aspa_objects": 2019,
|
||||
"roa_count": 916441,
|
||||
"atlas_asns_total": 4640,
|
||||
"atlas_asns_with_aspa": 424,
|
||||
"coverage_pct_atlas": 9.14,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-30",
|
||||
"ts": 1780174163595,
|
||||
"aspa_objects": 2026,
|
||||
"roa_count": 918362,
|
||||
"atlas_asns_total": 4655,
|
||||
"atlas_asns_with_aspa": 426,
|
||||
"coverage_pct_atlas": 9.15,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-31",
|
||||
"ts": 1780262359143,
|
||||
"aspa_objects": 2031,
|
||||
"roa_count": 918565,
|
||||
"atlas_asns_total": 4653,
|
||||
"atlas_asns_with_aspa": 427,
|
||||
"coverage_pct_atlas": 9.18,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"ts": 1780350554630,
|
||||
"aspa_objects": 2040,
|
||||
"roa_count": 920847,
|
||||
"atlas_asns_total": 4673,
|
||||
"atlas_asns_with_aspa": 430,
|
||||
"coverage_pct_atlas": 9.2,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-02",
|
||||
"ts": 1780438750094,
|
||||
"aspa_objects": 2050,
|
||||
"roa_count": 925338,
|
||||
"atlas_asns_total": 4685,
|
||||
"atlas_asns_with_aspa": 435,
|
||||
"coverage_pct_atlas": 9.28,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-03",
|
||||
"ts": 1780526945736,
|
||||
"aspa_objects": 2054,
|
||||
"roa_count": 926656,
|
||||
"atlas_asns_total": 4692,
|
||||
"atlas_asns_with_aspa": 440,
|
||||
"coverage_pct_atlas": 9.38,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-04",
|
||||
"ts": 1780615141853,
|
||||
"aspa_objects": 2052,
|
||||
"roa_count": 929579,
|
||||
"atlas_asns_total": 4699,
|
||||
"atlas_asns_with_aspa": 438,
|
||||
"coverage_pct_atlas": 9.32,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-05",
|
||||
"ts": 1780703337103,
|
||||
"aspa_objects": 2057,
|
||||
"roa_count": 933266,
|
||||
"atlas_asns_total": 4533,
|
||||
"atlas_asns_with_aspa": 423,
|
||||
"coverage_pct_atlas": 9.33,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-06",
|
||||
"ts": 1780776833375,
|
||||
"aspa_objects": 2057,
|
||||
"roa_count": 933707,
|
||||
"atlas_asns_total": 4457,
|
||||
"atlas_asns_with_aspa": 415,
|
||||
"coverage_pct_atlas": 9.31,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-07",
|
||||
"ts": 1780865028684,
|
||||
"aspa_objects": 2060,
|
||||
"roa_count": 934451,
|
||||
"atlas_asns_total": 4624,
|
||||
"atlas_asns_with_aspa": 432,
|
||||
"coverage_pct_atlas": 9.34,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-08",
|
||||
"ts": 1780953225583,
|
||||
"aspa_objects": 2064,
|
||||
"roa_count": 935363,
|
||||
"atlas_asns_total": 4673,
|
||||
"atlas_asns_with_aspa": 439,
|
||||
"coverage_pct_atlas": 9.39,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-09",
|
||||
"ts": 1781043490445,
|
||||
"aspa_objects": 2082,
|
||||
"roa_count": 938346,
|
||||
"atlas_asns_total": 4693,
|
||||
"atlas_asns_with_aspa": 446,
|
||||
"coverage_pct_atlas": 9.5,
|
||||
"aspa_delta": 4,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-10",
|
||||
"ts": 1781121824287,
|
||||
"aspa_objects": 2086,
|
||||
"roa_count": 942559,
|
||||
"atlas_asns_total": 4692,
|
||||
"atlas_asns_with_aspa": 444,
|
||||
"coverage_pct_atlas": 9.46,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-11",
|
||||
"ts": 1781218962694,
|
||||
"aspa_objects": 2101,
|
||||
"roa_count": 949614,
|
||||
"atlas_asns_total": 4721,
|
||||
"atlas_asns_with_aspa": 449,
|
||||
"coverage_pct_atlas": 9.51,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-12",
|
||||
"ts": 1781305669463,
|
||||
"aspa_objects": 2104,
|
||||
"roa_count": 950161,
|
||||
"atlas_asns_total": 4716,
|
||||
"atlas_asns_with_aspa": 447,
|
||||
"coverage_pct_atlas": 9.48,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-13",
|
||||
"ts": 1781392376261,
|
||||
"aspa_objects": 2112,
|
||||
"roa_count": 950030,
|
||||
"atlas_asns_total": 4727,
|
||||
"atlas_asns_with_aspa": 450,
|
||||
"coverage_pct_atlas": 9.52,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-14",
|
||||
"ts": 1781479082907,
|
||||
"aspa_objects": 2115,
|
||||
"roa_count": 950083,
|
||||
"atlas_asns_total": 4731,
|
||||
"atlas_asns_with_aspa": 451,
|
||||
"coverage_pct_atlas": 9.53,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-15",
|
||||
"ts": 1781565789140,
|
||||
"aspa_objects": 2119,
|
||||
"roa_count": 950996,
|
||||
"atlas_asns_total": 4736,
|
||||
"atlas_asns_with_aspa": 450,
|
||||
"coverage_pct_atlas": 9.5,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-16",
|
||||
"ts": 1781649652082,
|
||||
"aspa_objects": 2130,
|
||||
"roa_count": 953250,
|
||||
"atlas_asns_total": 4733,
|
||||
"atlas_asns_with_aspa": 450,
|
||||
"coverage_pct_atlas": 9.51,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-17",
|
||||
"ts": 1781734424437,
|
||||
"aspa_objects": 2133,
|
||||
"roa_count": 954510,
|
||||
"atlas_asns_total": 4726,
|
||||
"atlas_asns_with_aspa": 450,
|
||||
"coverage_pct_atlas": 9.52,
|
||||
"aspa_delta": -1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-18",
|
||||
"ts": 1781821528896,
|
||||
"aspa_objects": 2143,
|
||||
"roa_count": 957195,
|
||||
"atlas_asns_total": 4746,
|
||||
"atlas_asns_with_aspa": 450,
|
||||
"coverage_pct_atlas": 9.48,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-19",
|
||||
"ts": 1781908099618,
|
||||
"aspa_objects": 2146,
|
||||
"roa_count": 957896,
|
||||
"atlas_asns_total": 4748,
|
||||
"atlas_asns_with_aspa": 452,
|
||||
"coverage_pct_atlas": 9.52,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-20",
|
||||
"ts": 1781994672218,
|
||||
"aspa_objects": 2151,
|
||||
"roa_count": 958031,
|
||||
"atlas_asns_total": 4735,
|
||||
"atlas_asns_with_aspa": 452,
|
||||
"coverage_pct_atlas": 9.55,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-21",
|
||||
"ts": 1782081244003,
|
||||
"aspa_objects": 2156,
|
||||
"roa_count": 958067,
|
||||
"atlas_asns_total": 4739,
|
||||
"atlas_asns_with_aspa": 453,
|
||||
"coverage_pct_atlas": 9.56,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-22",
|
||||
"ts": 1782167816179,
|
||||
"aspa_objects": 2163,
|
||||
"roa_count": 958365,
|
||||
"atlas_asns_total": 4746,
|
||||
"atlas_asns_with_aspa": 456,
|
||||
"coverage_pct_atlas": 9.61,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-23",
|
||||
"ts": 1782253239274,
|
||||
"aspa_objects": 2173,
|
||||
"roa_count": 959022,
|
||||
"atlas_asns_total": 4743,
|
||||
"atlas_asns_with_aspa": 457,
|
||||
"coverage_pct_atlas": 9.64,
|
||||
"aspa_delta": -1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-24",
|
||||
"ts": 1782338059434,
|
||||
"aspa_objects": 2184,
|
||||
"roa_count": 959716,
|
||||
"atlas_asns_total": 4748,
|
||||
"atlas_asns_with_aspa": 459,
|
||||
"coverage_pct_atlas": 9.67,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-25",
|
||||
"ts": 1782422879426,
|
||||
"aspa_objects": 2194,
|
||||
"roa_count": 961420,
|
||||
"atlas_asns_total": 4749,
|
||||
"atlas_asns_with_aspa": 465,
|
||||
"coverage_pct_atlas": 9.79,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-26",
|
||||
"ts": 1782507700593,
|
||||
"aspa_objects": 2202,
|
||||
"roa_count": 961846,
|
||||
"atlas_asns_total": 4748,
|
||||
"atlas_asns_with_aspa": 462,
|
||||
"coverage_pct_atlas": 9.73,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-27",
|
||||
"ts": 1782592519478,
|
||||
"aspa_objects": 2208,
|
||||
"roa_count": 962630,
|
||||
"atlas_asns_total": 4746,
|
||||
"atlas_asns_with_aspa": 459,
|
||||
"coverage_pct_atlas": 9.67,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-28",
|
||||
"ts": 1782677339653,
|
||||
"aspa_objects": 2210,
|
||||
"roa_count": 962824,
|
||||
"atlas_asns_total": 4738,
|
||||
"atlas_asns_with_aspa": 462,
|
||||
"coverage_pct_atlas": 9.75,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-29",
|
||||
"ts": 1782776296119,
|
||||
"aspa_objects": 2218,
|
||||
"roa_count": 963499,
|
||||
"atlas_asns_total": 4749,
|
||||
"atlas_asns_with_aspa": 462,
|
||||
"coverage_pct_atlas": 9.73,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-30",
|
||||
"ts": 1782861116475,
|
||||
"aspa_objects": 2227,
|
||||
"roa_count": 964524,
|
||||
"atlas_asns_total": 4747,
|
||||
"atlas_asns_with_aspa": 463,
|
||||
"coverage_pct_atlas": 9.75,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"ts": 1782943528014,
|
||||
"aspa_objects": 2234,
|
||||
"roa_count": 965150,
|
||||
"atlas_asns_total": 4747,
|
||||
"atlas_asns_with_aspa": 470,
|
||||
"coverage_pct_atlas": 9.9,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-02",
|
||||
"ts": 1783029743491,
|
||||
"aspa_objects": 2244,
|
||||
"roa_count": 965634,
|
||||
"atlas_asns_total": 4744,
|
||||
"atlas_asns_with_aspa": 473,
|
||||
"coverage_pct_atlas": 9.97,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"ts": 1783110319303,
|
||||
"aspa_objects": 2253,
|
||||
"roa_count": 965995,
|
||||
"atlas_asns_total": 4739,
|
||||
"atlas_asns_with_aspa": 478,
|
||||
"coverage_pct_atlas": 10.09,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-04",
|
||||
"ts": 1783195400105,
|
||||
"aspa_objects": 2254,
|
||||
"roa_count": 966049,
|
||||
"atlas_asns_total": 4757,
|
||||
"atlas_asns_with_aspa": 480,
|
||||
"coverage_pct_atlas": 10.09,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-05",
|
||||
"ts": 1783282307341,
|
||||
"aspa_objects": 2264,
|
||||
"roa_count": 966292,
|
||||
"atlas_asns_total": 4740,
|
||||
"atlas_asns_with_aspa": 483,
|
||||
"coverage_pct_atlas": 10.19,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-06",
|
||||
"ts": 1783368549946,
|
||||
"aspa_objects": 2275,
|
||||
"roa_count": 966360,
|
||||
"atlas_asns_total": 4736,
|
||||
"atlas_asns_with_aspa": 482,
|
||||
"coverage_pct_atlas": 10.18,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"ts": 1783464285059,
|
||||
"aspa_objects": 2286,
|
||||
"roa_count": 967521,
|
||||
"atlas_asns_total": 4747,
|
||||
"atlas_asns_with_aspa": 483,
|
||||
"coverage_pct_atlas": 10.17,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"ts": 1783550943346,
|
||||
"aspa_objects": 2309,
|
||||
"roa_count": 967933,
|
||||
"atlas_asns_total": 4750,
|
||||
"atlas_asns_with_aspa": 486,
|
||||
"coverage_pct_atlas": 10.23,
|
||||
"aspa_delta": 1,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"ts": 1783637598811,
|
||||
"aspa_objects": 2324,
|
||||
"roa_count": 969050,
|
||||
"atlas_asns_total": 4759,
|
||||
"atlas_asns_with_aspa": 492,
|
||||
"coverage_pct_atlas": 10.34,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"ts": 1783724254815,
|
||||
"aspa_objects": 2347,
|
||||
"roa_count": 970180,
|
||||
"atlas_asns_total": 4766,
|
||||
"atlas_asns_with_aspa": 495,
|
||||
"coverage_pct_atlas": 10.39,
|
||||
"aspa_delta": 5,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-11",
|
||||
"ts": 1783810912350,
|
||||
"aspa_objects": 2352,
|
||||
"roa_count": 970494,
|
||||
"atlas_asns_total": 4765,
|
||||
"atlas_asns_with_aspa": 495,
|
||||
"coverage_pct_atlas": 10.39,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
"ts": 1783897568710,
|
||||
"aspa_objects": 2355,
|
||||
"roa_count": 970426,
|
||||
"atlas_asns_total": 4769,
|
||||
"atlas_asns_with_aspa": 500,
|
||||
"coverage_pct_atlas": 10.48,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"ts": 1783984224592,
|
||||
"aspa_objects": 2361,
|
||||
"roa_count": 970721,
|
||||
"atlas_asns_total": 4780,
|
||||
"atlas_asns_with_aspa": 505,
|
||||
"coverage_pct_atlas": 10.56,
|
||||
"aspa_delta": 3,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-14",
|
||||
"ts": 1784071182188,
|
||||
"aspa_objects": 2373,
|
||||
"roa_count": 971705,
|
||||
"atlas_asns_total": 0,
|
||||
"atlas_asns_with_aspa": 0,
|
||||
"coverage_pct_atlas": 0,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-15",
|
||||
"ts": 1784159361526,
|
||||
"aspa_objects": 2382,
|
||||
"roa_count": 971732,
|
||||
"atlas_asns_total": 4775,
|
||||
"atlas_asns_with_aspa": 504,
|
||||
"coverage_pct_atlas": 10.55,
|
||||
"aspa_delta": 2,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-16",
|
||||
"ts": 1784233075736,
|
||||
"aspa_objects": 2396,
|
||||
"roa_count": 972051,
|
||||
"atlas_asns_total": 4772,
|
||||
"atlas_asns_with_aspa": 506,
|
||||
"coverage_pct_atlas": 10.6,
|
||||
"aspa_delta": 0,
|
||||
"method": "rpki_cloudflare_feed"
|
||||
}
|
||||
]
|
||||
1
audit/asn_pool.json
Normal file
1
audit/asn_pool.json
Normal file
File diff suppressed because one or more lines are too long
176
audit/daily_audit.py
Executable file
176
audit/daily_audit.py
Executable file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PeerCortex Final Quality Audit v4
|
||||
Tests 103 ASNs against v0.6.7 (running, stable)
|
||||
Uses cached responses where available, skips ASNs that don't respond in 15s
|
||||
Cross-validates: prefix counts, RPKI math, IX dedup, facilities, RIR, country
|
||||
"""
|
||||
import json, urllib.request, time, sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def fetch(url, timeout=15):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PeerCortex-Audit/4.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
ASNS = [
|
||||
1, 174, 714, 1299, 2497, 2516, 2906, 3257, 3303, 3320, 3356,
|
||||
4134, 4766, 5089, 5483, 5511, 6057, 6128, 6147, 6453, 6695,
|
||||
6762, 6830, 6939, 7018, 7473, 7545, 7922, 8075, 8151, 8220,
|
||||
8422, 8708, 9121, 9304, 9318, 9498, 10429, 10474, 10796, 12479,
|
||||
12714, 12956, 13237, 13335, 14080, 15133, 15169, 15600, 15704,
|
||||
16276, 16509, 17676, 18403, 19281, 20115, 20562, 20932, 20940,
|
||||
21928, 22773, 22822, 23106, 24115, 24429, 24940, 24961, 25091,
|
||||
29049, 32934, 33891, 34465, 34549, 35369, 37100, 38001, 39122,
|
||||
41327, 42, 42739, 43350, 43996, 44574, 45899, 47692, 48159,
|
||||
50629, 51088, 54113, 56630, 57695, 58057, 59947, 60068, 60501,
|
||||
62240, 64271, 132335, 136907, 137409, 139070, 197540, 199121,
|
||||
]
|
||||
|
||||
results = {"ok": 0, "warn": 0, "crit": 0, "skip": 0, "total": len(ASNS)}
|
||||
all_issues = []
|
||||
responded = []
|
||||
|
||||
print(f"PeerCortex Final Audit v4 — {datetime.now(timezone.utc).isoformat()}")
|
||||
print(f"Target: http://localhost:3101 | ASNs: {len(ASNS)}")
|
||||
print("=" * 75)
|
||||
|
||||
for i, asn in enumerate(ASNS):
|
||||
sys.stdout.write(f"\r [{i+1:3d}/{len(ASNS)}] AS{asn}... ")
|
||||
sys.stdout.flush()
|
||||
|
||||
d = fetch(f"http://localhost:3101/api/lookup?asn={asn}", timeout=15)
|
||||
if not d or "network" not in d or d.get("error"):
|
||||
results["skip"] += 1
|
||||
continue
|
||||
|
||||
responded.append(asn)
|
||||
n = d.get("network", {})
|
||||
p = d.get("prefixes", {})
|
||||
r = d.get("rpki", {})
|
||||
ix = d.get("ix_presence", {})
|
||||
fac = d.get("facilities", {})
|
||||
meta= d.get("meta", {})
|
||||
reg = d.get("registration", {})
|
||||
|
||||
issues = []
|
||||
|
||||
# ── Math checks ─────────────────────────────────────────
|
||||
if p.get("ipv4", 0) + p.get("ipv6", 0) != p.get("total", -1):
|
||||
issues.append(("CRITICAL", "prefix_math",
|
||||
f"v4({p.get('ipv4')})+v6({p.get('ipv6')})={p.get('ipv4',0)+p.get('ipv6',0)} ≠ total({p.get('total')})"))
|
||||
|
||||
rpki_sum = r.get("valid",0) + r.get("invalid",0) + r.get("not_found",0)
|
||||
if rpki_sum != r.get("checked", 0):
|
||||
issues.append(("CRITICAL", "rpki_sum",
|
||||
f"{rpki_sum} ≠ checked({r.get('checked')})"))
|
||||
|
||||
if r.get("checked", 0) > 0:
|
||||
exp = round(r.get("valid",0) / r.get("checked") * 100)
|
||||
if abs(exp - r.get("coverage_percent", 0)) > 1:
|
||||
issues.append(("CRITICAL", "rpki_pct",
|
||||
f"expected {exp}% got {r.get('coverage_percent')}%"))
|
||||
|
||||
conns = ix.get("connections", [])
|
||||
dedup = len(set(c.get("ix_id") for c in conns if c.get("ix_id")))
|
||||
if dedup != ix.get("unique_ixps", 0):
|
||||
issues.append(("CRITICAL", "ix_dedup",
|
||||
f"dedup={dedup} ≠ unique_ixps={ix.get('unique_ixps')}"))
|
||||
|
||||
if len(conns) != ix.get("total_connections", 0):
|
||||
issues.append(("WARNING", "ix_total",
|
||||
f"len={len(conns)} ≠ total_connections={ix.get('total_connections')}"))
|
||||
|
||||
if len(fac.get("list",[])) != fac.get("total", 0):
|
||||
issues.append(("WARNING", "fac_total",
|
||||
f"len={len(fac.get('list',[]))} ≠ total={fac.get('total')}"))
|
||||
|
||||
# ── Mandatory fields ─────────────────────────────────────
|
||||
if not n.get("name") or n.get("name") == "Unknown":
|
||||
issues.append(("CRITICAL", "name_empty", f"name={n.get('name')!r}"))
|
||||
|
||||
if not n.get("asn"):
|
||||
issues.append(("CRITICAL", "asn_empty", "ASN field missing"))
|
||||
|
||||
if not n.get("rir") and not reg.get("rir"):
|
||||
issues.append(("WARNING", "rir_empty", f"rir empty, country={n.get('country')!r}"))
|
||||
|
||||
if not n.get("country"):
|
||||
issues.append(("WARNING", "country_empty", "country field empty"))
|
||||
|
||||
if meta.get("version") != "0.6.7":
|
||||
issues.append(("WARNING", "version", f"got {meta.get('version')}"))
|
||||
|
||||
# ── Categorize ───────────────────────────────────────────
|
||||
crits = [x for x in issues if x[0] == "CRITICAL"]
|
||||
warns = [x for x in issues if x[0] == "WARNING"]
|
||||
|
||||
if crits:
|
||||
results["crit"] += 1
|
||||
elif warns:
|
||||
results["warn"] += 1
|
||||
else:
|
||||
results["ok"] += 1
|
||||
|
||||
if issues:
|
||||
all_issues.append((asn, n.get("name","?"), issues,
|
||||
p.get("total"), r.get("coverage_percent"),
|
||||
ix.get("total_connections"), fac.get("total"),
|
||||
n.get("rir"), n.get("country")))
|
||||
|
||||
print(f"\r Done. {len(responded)} responded, {results['skip']} skipped. ")
|
||||
|
||||
print()
|
||||
print("=" * 75)
|
||||
print(f"FINAL AUDIT SUMMARY — {len(responded)} ASNs responded ({results['skip']} timed out)")
|
||||
print("=" * 75)
|
||||
print(f" ✓ PERFECT (zero issues): {results['ok']:3d}")
|
||||
print(f" ⚠ WARNING only: {results['warn']:3d}")
|
||||
print(f" ✗ CRITICAL: {results['crit']:3d}")
|
||||
print(f" ⏭ Skipped (no response): {results['skip']:3d}")
|
||||
|
||||
if results["crit"] == 0:
|
||||
print("\n ✅ NO CRITICAL DATA ERRORS IN ANY RESPONDED ASN")
|
||||
|
||||
# Per-category counts
|
||||
warn_counts = {}
|
||||
crit_counts = {}
|
||||
for _, _, issues, *_ in all_issues:
|
||||
for sev, field, _ in issues:
|
||||
if sev == "CRITICAL":
|
||||
crit_counts[field] = crit_counts.get(field, 0) + 1
|
||||
else:
|
||||
warn_counts[field] = warn_counts.get(field, 0) + 1
|
||||
|
||||
if crit_counts:
|
||||
print("\n CRITICAL by field:")
|
||||
for f, c in sorted(crit_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {f:35s}: {c} ASNs")
|
||||
|
||||
if warn_counts:
|
||||
print("\n WARNING by field:")
|
||||
for f, c in sorted(warn_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {f:35s}: {c} ASNs")
|
||||
|
||||
# Detail for any issues
|
||||
if all_issues:
|
||||
print("\n DETAILS:")
|
||||
for asn, name, issues, pfx, rpki_pct, ix_cnt, fac_cnt, rir, cc in all_issues:
|
||||
for sev, field, msg in issues:
|
||||
print(f" [{sev[:4]}] AS{asn:>7} | {field:30s} | {msg[:60]}")
|
||||
|
||||
print()
|
||||
print(" SAMPLE DATA (responded ASNs):")
|
||||
print(f" {'ASN':>8} {'Name':24s} {'RIR':>6} {'CC':>3} {'Pfx':>5} {'RPKI%':>6} {'IX':>4} {'Fac':>4}")
|
||||
print(f" {'-'*70}")
|
||||
for asn in responded[:25]:
|
||||
d = fetch(f"http://localhost:3101/api/lookup?asn={asn}", timeout=5)
|
||||
if not d: continue
|
||||
n=d.get("network",{}); p=d.get("prefixes",{}); r=d.get("rpki",{}); ix=d.get("ix_presence",{}); fac=d.get("facilities",{})
|
||||
print(f" AS{asn:>6} {n.get('name','?')[:22]:24s} {n.get('rir','?'):>6} {n.get('country','?'):>3} {p.get('total',0):>5} {r.get('coverage_percent',0):>5}% {ix.get('total_connections',0):>4} {fac.get('total',0):>4}")
|
||||
|
||||
print()
|
||||
print(f"Audit complete: {datetime.now(timezone.utc).isoformat()}")
|
||||
270
audit/rotating_audit.py
Normal file
270
audit/rotating_audit.py
Normal file
@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PeerCortex Rotating Daily Audit
|
||||
- Pool of 500+ ASNs
|
||||
- Each day: deterministic 100-ASN selection (seeded by date → reproducible, never same day twice)
|
||||
- Each ASN: PeerCortex response cross-validated against RIPE Stat + PeeringDB externally
|
||||
- Math checks: prefix sums, RPKI sums, IX dedup, facility counts
|
||||
- Results saved to /var/log/peercortex/audit-YYYY-MM-DD.json
|
||||
"""
|
||||
import json, urllib.request, urllib.error, time, sys, os
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
|
||||
# ── ASN Pool (500+ diverse networks across all RIRs) ──────────────────────────
|
||||
ASN_POOL = [
|
||||
# Tier-1 / Large Transit
|
||||
1, 174, 209, 286, 701, 702, 1239, 1273, 1280, 1299, 2914, 3257, 3320,
|
||||
3356, 3491, 3549, 3561, 4134, 4637, 4657, 5511, 6453, 6461, 6762, 6830,
|
||||
7018, 7473, 7922, 8220, 8928, 9002, 9304, 12956, 13030,
|
||||
# Cloud / Hyperscaler
|
||||
714, 2906, 8075, 13335, 15169, 16509, 16591, 20940, 21342, 32934, 36351,
|
||||
54113, 136907, 14061, 19604, 63949, 22616,
|
||||
# European ISPs
|
||||
1257, 1853, 2119, 2603, 3209, 3215, 3216, 3233, 3243, 3269, 3292, 3301,
|
||||
3303, 3308, 3327, 5089, 5390, 5432, 5483, 6057, 6128, 6147, 6695, 6728,
|
||||
6830, 6939, 8220, 8359, 8400, 8422, 8447, 8468, 8473, 8551, 8708, 9121,
|
||||
9145, 9158, 12714, 13237, 15435, 15547, 15600, 15692, 15830, 16276,
|
||||
20562, 20932, 21202, 24940, 24961, 25091, 28716, 29049, 29208, 33891,
|
||||
34465, 34549, 35369, 39122, 41327, 42, 42739, 43350, 47692, 48159,
|
||||
50629, 51088, 58057, 197540, 199121,
|
||||
# APNIC
|
||||
2497, 2516, 4766, 7545, 9318, 9498, 17676, 18403, 23969, 24115, 38001,
|
||||
45899, 55836, 132335, 136907, 137409, 139070, 4713, 9269, 17488, 23944,
|
||||
38197, 45352, 63927, 131090, 133481, 134762, 137718, 149440,
|
||||
# ARIN
|
||||
701, 1239, 1280, 2828, 3549, 3561, 6128, 6939, 7018, 7922, 10429, 10796,
|
||||
11427, 13977, 14080, 15133, 19281, 20115, 20325, 21928, 22773, 22822,
|
||||
23106, 32934, 33070, 35911, 36351, 40191, 43996, 54113, 62240, 64271,
|
||||
393398, 11404, 14618, 14593, 7046, 26101, 10310, 32590,
|
||||
# LACNIC
|
||||
6147, 8167, 10429, 10474, 11419, 11644, 14080, 18881, 22927, 23106,
|
||||
28573, 52320, 53013, 61832, 262589, 263702,
|
||||
# AFRINIC
|
||||
10474, 12091, 23889, 24835, 30844, 36864, 36903, 37100, 37271, 37468,
|
||||
55330, 327814,
|
||||
# IXPs / Route Servers
|
||||
6695, 24115, 34307, 42476, 43366, 47498, 56393,
|
||||
# CDN / Hosting
|
||||
2906, 8100, 13335, 15133, 16509, 20940, 22822, 24429, 46489, 54113,
|
||||
60068, 60501, 62240, 64271, 132335,
|
||||
# Security / DNS
|
||||
19281, 15169, 13335, 714, 20473,
|
||||
# Mixed / Small
|
||||
29791, 34224, 35332, 39386, 41695, 42473, 44574, 47065, 48858, 50300,
|
||||
56630, 57695, 58057, 59947, 60068, 60501, 63023, 64271, 132335,
|
||||
# More European
|
||||
1200, 2611, 3265, 5388, 5430, 5444, 6128, 8309, 8365, 8560, 8607,
|
||||
9009, 12897, 13285, 15516, 16150, 20738, 21263, 24586, 24875, 25369,
|
||||
28917, 29017, 30036, 30781, 31025, 31400, 33986, 34006, 34224, 34655,
|
||||
35432, 39386, 39737, 41327, 41552, 42652, 43531, 44574, 47065,
|
||||
# Telcos worldwide
|
||||
2516, 3320, 3786, 4766, 4800, 5483, 7473, 8151, 8708, 9121, 9304,
|
||||
9318, 9498, 10429, 12479, 12714, 15704, 16276, 17676, 18403, 22773,
|
||||
]
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
ASN_POOL_DEDUP = [x for x in ASN_POOL if not (x in seen or seen.add(x))]
|
||||
|
||||
def load_pool():
|
||||
"""Load ASN pool from file, fall back to built-in list."""
|
||||
pool_file = os.path.join(os.path.dirname(__file__), 'asn_pool.json')
|
||||
try:
|
||||
with open(pool_file) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return ASN_POOL_DEDUP
|
||||
|
||||
def select_daily_asns(date_str, count=100):
|
||||
"""Deterministic daily selection — same date always gives same 100 ASNs."""
|
||||
import random
|
||||
pool = load_pool()
|
||||
seed = int(sha256(date_str.encode()).hexdigest(), 16)
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(pool)
|
||||
return sorted(pool[:count])
|
||||
|
||||
def fetch(url, timeout=12):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PeerCortex-Audit/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except:
|
||||
return None
|
||||
|
||||
def fetch_pc(asn, timeout=20):
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://localhost:3101/api/lookup?asn={asn}", timeout=timeout) as r:
|
||||
return json.loads(r.read())
|
||||
except:
|
||||
return None
|
||||
|
||||
def audit_asn(asn):
|
||||
issues = []
|
||||
result = {"asn": asn, "status": "ok", "issues": [], "data": {}}
|
||||
|
||||
# ── 1. PeerCortex response ───────────────────────────────────────────────
|
||||
d = fetch_pc(asn)
|
||||
if not d or "network" not in d or d.get("error"):
|
||||
result["status"] = "skip"
|
||||
result["issues"].append(("SKIP", "no_response", "PeerCortex did not respond"))
|
||||
return result
|
||||
|
||||
n = d.get("network", {})
|
||||
p = d.get("prefixes", {})
|
||||
r = d.get("rpki", {})
|
||||
ix = d.get("ix_presence", {})
|
||||
fac = d.get("facilities", {})
|
||||
meta= d.get("meta", {})
|
||||
|
||||
result["data"] = {
|
||||
"name": n.get("name"),
|
||||
"rir": n.get("rir"),
|
||||
"cc": n.get("country"),
|
||||
"pfx_total": p.get("total", 0),
|
||||
"rpki_pct": r.get("coverage_percent", 0),
|
||||
"ix_cnt": ix.get("total_connections", 0),
|
||||
"fac_cnt": fac.get("total", 0),
|
||||
"version": meta.get("version"),
|
||||
}
|
||||
|
||||
# ── 2. Math checks (internal consistency) ───────────────────────────────
|
||||
if p.get("ipv4", 0) + p.get("ipv6", 0) != p.get("total", -1):
|
||||
issues.append(("CRITICAL", "prefix_math",
|
||||
f"v4({p.get('ipv4')})+v6({p.get('ipv6')}) ≠ total({p.get('total')})"))
|
||||
|
||||
rpki_sum = r.get("valid", 0) + r.get("invalid", 0) + r.get("not_found", 0)
|
||||
if rpki_sum != r.get("checked", 0):
|
||||
issues.append(("CRITICAL", "rpki_sum",
|
||||
f"{rpki_sum} ≠ checked({r.get('checked')})"))
|
||||
|
||||
if r.get("checked", 0) > 0:
|
||||
exp = round(r.get("valid", 0) / r.get("checked") * 100)
|
||||
if abs(exp - r.get("coverage_percent", 0)) > 1:
|
||||
issues.append(("CRITICAL", "rpki_pct",
|
||||
f"expected {exp}% got {r.get('coverage_percent')}%"))
|
||||
|
||||
conns = ix.get("connections", [])
|
||||
dedup = len(set(c.get("ix_id") for c in conns if c.get("ix_id")))
|
||||
if dedup != ix.get("unique_ixps", 0):
|
||||
issues.append(("CRITICAL", "ix_dedup",
|
||||
f"dedup={dedup} ≠ unique_ixps={ix.get('unique_ixps')}"))
|
||||
|
||||
if len(conns) != ix.get("total_connections", 0):
|
||||
issues.append(("WARNING", "ix_total",
|
||||
f"len={len(conns)} ≠ total_connections={ix.get('total_connections')}"))
|
||||
|
||||
if len(fac.get("list", [])) != fac.get("total", 0):
|
||||
issues.append(("WARNING", "fac_total",
|
||||
f"len={len(fac.get('list',[]))} ≠ total={fac.get('total')}"))
|
||||
|
||||
# ── 3. Mandatory fields ──────────────────────────────────────────────────
|
||||
if not n.get("name") or n.get("name") == "Unknown":
|
||||
issues.append(("CRITICAL", "name_empty", f"name={n.get('name')!r}"))
|
||||
|
||||
if not n.get("asn"):
|
||||
issues.append(("CRITICAL", "asn_field", "ASN field missing in response"))
|
||||
|
||||
if not n.get("rir"):
|
||||
issues.append(("WARNING", "rir_empty", f"rir empty, country={n.get('country')!r}"))
|
||||
|
||||
if not n.get("country"):
|
||||
issues.append(("WARNING", "country_empty", "country field empty"))
|
||||
|
||||
# ── 4. External cross-validation: RIPE Stat prefix count ────────────────
|
||||
ripe = fetch(f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}")
|
||||
if ripe and ripe.get("data", {}).get("prefixes") is not None:
|
||||
ripe_pfx = len(ripe["data"]["prefixes"])
|
||||
pc_pfx = p.get("total", 0)
|
||||
result["data"]["ripe_stat_pfx"] = ripe_pfx
|
||||
if ripe_pfx > 0 and pc_pfx > 0:
|
||||
ratio = min(ripe_pfx, pc_pfx) / max(ripe_pfx, pc_pfx)
|
||||
if ratio < 0.85:
|
||||
issues.append(("WARNING", "prefix_ext_mismatch",
|
||||
f"PC={pc_pfx} vs RIPE Stat={ripe_pfx} ({round((1-ratio)*100)}% diff)"))
|
||||
elif ripe_pfx == 0 and pc_pfx > 0:
|
||||
issues.append(("WARNING", "prefix_ext_zero",
|
||||
f"RIPE Stat reports 0 prefixes, PC shows {pc_pfx}"))
|
||||
|
||||
# ── 5. External cross-validation: PeeringDB IX count ────────────────────
|
||||
pdb = fetch(f"https://www.peeringdb.com/api/netixlan?asn={asn}&depth=0")
|
||||
if pdb and isinstance(pdb.get("data"), list):
|
||||
pdb_ix = len(pdb["data"])
|
||||
pc_ix = ix.get("total_connections", 0)
|
||||
result["data"]["pdb_ix_cnt"] = pdb_ix
|
||||
if pdb_ix > 0 and pc_ix > 0:
|
||||
ratio = min(pdb_ix, pc_ix) / max(pdb_ix, pc_ix)
|
||||
if ratio < 0.85:
|
||||
issues.append(("WARNING", "ix_ext_mismatch",
|
||||
f"PC={pc_ix} vs PeeringDB={pdb_ix} ({round((1-ratio)*100)}% diff)"))
|
||||
|
||||
# ── Categorize ───────────────────────────────────────────────────────────
|
||||
result["issues"] = issues
|
||||
crits = [x for x in issues if x[0] == "CRITICAL"]
|
||||
warns = [x for x in issues if x[0] == "WARNING"]
|
||||
if crits:
|
||||
result["status"] = "critical"
|
||||
elif warns:
|
||||
result["status"] = "warning"
|
||||
else:
|
||||
result["status"] = "ok"
|
||||
|
||||
return result
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
asns = select_daily_asns(date_str, 100)
|
||||
start = time.time()
|
||||
|
||||
print(f"PeerCortex Rotating Audit — {date_str}")
|
||||
print(f"Pool: {len(ASN_POOL_DEDUP)} ASNs | Today: {len(asns)} selected")
|
||||
print("=" * 70)
|
||||
|
||||
results = []
|
||||
counts = {"ok": 0, "warning": 0, "critical": 0, "skip": 0}
|
||||
|
||||
for i, asn in enumerate(asns):
|
||||
sys.stdout.write(f"\r [{i+1:3d}/100] AS{asn}... ")
|
||||
sys.stdout.flush()
|
||||
r = audit_asn(asn)
|
||||
results.append(r)
|
||||
counts[r["status"]] += 1
|
||||
time.sleep(0.3) # be gentle to external APIs
|
||||
|
||||
elapsed = time.time() - start
|
||||
print(f"\r Done in {elapsed:.0f}s" + " " * 30)
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(f"SUMMARY — {date_str}")
|
||||
print("=" * 70)
|
||||
print(f" ✓ PERFECT: {counts['ok']:3d}")
|
||||
print(f" ⚠ WARNING: {counts['warning']:3d}")
|
||||
print(f" ✗ CRITICAL: {counts['critical']:3d}")
|
||||
print(f" ⏭ SKIP: {counts['skip']:3d}")
|
||||
|
||||
issues_found = [r for r in results if r["status"] in ("critical", "warning")]
|
||||
if issues_found:
|
||||
print(f"\n ISSUES ({len(issues_found)} ASNs):")
|
||||
for r in issues_found:
|
||||
for sev, field, msg in r["issues"]:
|
||||
print(f" [{sev[:4]}] AS{r['asn']:>7} | {field:30s} | {msg[:55]}")
|
||||
|
||||
# ── Save JSON report ──────────────────────────────────────────────────────
|
||||
report = {
|
||||
"date": date_str,
|
||||
"elapsed_s": round(elapsed),
|
||||
"asns_tested": asns,
|
||||
"pool_size": len(ASN_POOL_DEDUP),
|
||||
"counts": counts,
|
||||
"results": results,
|
||||
"generated": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
out_path = f"/var/log/peercortex/audit-{date_str}.json"
|
||||
os.makedirs("/var/log/peercortex", exist_ok=True)
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"\nReport: {out_path}")
|
||||
print(f"Audit complete: {datetime.now(timezone.utc).isoformat()}")
|
||||
6
audit/run_daily_audit.sh
Executable file
6
audit/run_daily_audit.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
LOG=/var/log/peercortex/audit-${DATE}.log
|
||||
find /var/log/peercortex/ -name 'audit-*.log' -mtime +30 -delete 2>/dev/null
|
||||
find /var/log/peercortex/ -name 'audit-*.json' -mtime +30 -delete 2>/dev/null
|
||||
python3 /opt/peercortex-app/audit/rotating_audit.py > "$LOG" 2>&1
|
||||
226
audit/send_audit_report.py
Executable file
226
audit/send_audit_report.py
Executable file
@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PeerCortex Daily Audit Mailer — detailed report via localhost:25"""
|
||||
import os, json, smtplib
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import make_msgid, formatdate
|
||||
|
||||
SMTP_HOST = "localhost"
|
||||
SMTP_PORT = 25
|
||||
MAIL_FROM = "ctx-report@fichtmueller.org"
|
||||
MAIL_TO = "rf@flexoptix.net"
|
||||
|
||||
def td(content, color=None, align="left", mono=False, bold=False):
|
||||
ff = "monospace" if mono else "sans-serif"
|
||||
fw = "font-weight:700;" if bold else ""
|
||||
cl = "color:%s;" % color if color else ""
|
||||
return '<td style="font-family:%s;font-size:11px;padding:3px 8px;text-align:%s;%s%s">%s</td>' % (ff, align, cl, fw, content)
|
||||
|
||||
def load_report():
|
||||
for delta in [0, 1]:
|
||||
d = (datetime.now(timezone.utc) - timedelta(days=delta)).strftime("%Y-%m-%d")
|
||||
try:
|
||||
with open("/var/log/peercortex/audit-%s.json" % d) as f:
|
||||
return json.load(f), d
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return None, datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
def build(report, date_str):
|
||||
if not report:
|
||||
return ("<html><body style='background:#0f172a;color:#e2e8f0;padding:24px'><h2>No report for %s</h2></body></html>" % date_str,
|
||||
"PeerCortex Audit %s — no report" % date_str)
|
||||
|
||||
counts = report.get("counts", {})
|
||||
results = report.get("results", [])
|
||||
elapsed = report.get("elapsed_s", 0)
|
||||
pool = report.get("pool_size", 0)
|
||||
ok = counts.get("ok", 0)
|
||||
warn = counts.get("warning", 0)
|
||||
crit = counts.get("critical", 0)
|
||||
skip = counts.get("skip", 0)
|
||||
total = len(results)
|
||||
mins, secs = elapsed // 60, elapsed % 60
|
||||
|
||||
if crit == 0 and warn == 0:
|
||||
sc, emoji, text, spfx = "#22c55e", "✅", "All Clear — no issues detected", "✅ All Clear"
|
||||
elif crit == 0:
|
||||
sc, emoji, text, spfx = "#f97316", "⚠️", "%d warning(s)" % warn, "⚠️ %d Warning(s)" % warn
|
||||
else:
|
||||
sc, emoji, text, spfx = "#ef4444", "🚨", "%d CRITICAL issue(s) — action required" % crit, "🚨 %d CRITICAL" % crit
|
||||
|
||||
subject = "PeerCortex Daily Audit %s — %s" % (date_str, spfx)
|
||||
|
||||
# Issues
|
||||
issue_results = [r for r in results if r.get("status") in ("critical", "warning")]
|
||||
issues_html = ""
|
||||
if issue_results:
|
||||
rows = ""
|
||||
for r in issue_results:
|
||||
asn = str(r["asn"])
|
||||
name = (r.get("data", {}).get("name") or "?")[:28]
|
||||
for sev, field, msg in r.get("issues", []):
|
||||
c = "#ef4444" if sev == "CRITICAL" else "#f97316"
|
||||
rows += "<tr style='border-bottom:1px solid #1e293b'>%s%s%s%s%s</tr>" % (
|
||||
td("[%s]" % sev[:4], c, mono=True),
|
||||
td("AS" + asn, "#94a3b8", mono=True),
|
||||
td(name, "#e2e8f0"),
|
||||
td(field, "#64748b", mono=True),
|
||||
td(msg[:60], "#94a3b8"))
|
||||
issues_html = (
|
||||
"<h3 style='color:#f97316;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>⚠ Issues (%d ASNs)</h3>" % len(issue_results) +
|
||||
"<table style='width:100%%;border-collapse:collapse;background:#111827;border-radius:6px;overflow:hidden'>%s</table>" % rows
|
||||
)
|
||||
|
||||
# Skipped
|
||||
skip_results = [r for r in results if r.get("status") == "skip"]
|
||||
skipped_html = ""
|
||||
if skip_results:
|
||||
rows = ""
|
||||
for r in skip_results:
|
||||
asn = str(r["asn"])
|
||||
reason = r.get("issues", [["SKIP","","no response from PeerCortex"]])[0][2] if r.get("issues") else "no response from PeerCortex"
|
||||
url = "https://peercortex.org/?asn=" + asn
|
||||
rows += ("<tr style='border-bottom:1px solid #1e293b'>%s%s<td style='font-size:11px;padding:3px 8px'>"
|
||||
"<a href='%s' style='color:#3b82f6;text-decoration:none'>→ lookup</a></td></tr>") % (
|
||||
td("AS" + asn, "#94a3b8", mono=True),
|
||||
td(reason, "#64748b"),
|
||||
url)
|
||||
skipped_html = (
|
||||
"<h3 style='color:#475569;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>⏭ Skipped ASNs (%d) — no PeerCortex response</h3>" % skip +
|
||||
"<p style='color:#475569;font-size:11px;margin:0 0 8px'>Possible causes: ASN not in any routing table, very large prefix count, or temporary API timeout.</p>" +
|
||||
"<table style='width:100%%;border-collapse:collapse;background:#111827;border-radius:6px;overflow:hidden'>%s</table>" % rows
|
||||
)
|
||||
|
||||
# Low RPKI
|
||||
rpki_bad = sorted(
|
||||
[r for r in results if r.get("status") != "skip" and r.get("data", {}).get("rpki_pct", 100) < 60],
|
||||
key=lambda r: r.get("data", {}).get("rpki_pct", 100)
|
||||
)[:8]
|
||||
rpki_html = ""
|
||||
if rpki_bad:
|
||||
rows = ""
|
||||
for r in rpki_bad:
|
||||
d = r.get("data", {})
|
||||
pct = d.get("rpki_pct", 0)
|
||||
bar = ("<div style='background:#1e293b;border-radius:3px;height:8px;width:100px;display:inline-block'>"
|
||||
"<div style='background:#ef4444;border-radius:3px;height:8px;width:%d%%'></div></div>") % max(2, pct)
|
||||
rows += "<tr style='border-bottom:1px solid #1e293b'>%s%s%s<td style='padding:3px 8px'>%s</td>%s</tr>" % (
|
||||
td("AS" + str(r["asn"]), "#94a3b8", mono=True),
|
||||
td((d.get("name") or "?")[:28], "#e2e8f0"),
|
||||
td(d.get("rir", "?"), "#64748b", "center"),
|
||||
bar,
|
||||
td(str(pct) + "%", "#ef4444", "right", bold=True))
|
||||
rpki_html = (
|
||||
"<h3 style='color:#94a3b8;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>📉 Low RPKI Coverage (<60%%)</h3>" +
|
||||
"<table style='width:100%%;border-collapse:collapse;background:#111827;border-radius:6px;overflow:hidden'>%s</table>" % rows
|
||||
)
|
||||
|
||||
# All ASNs table
|
||||
sorted_results = sorted([r for r in results if r.get("status") != "skip"], key=lambda x: x["asn"])
|
||||
header = ("<tr style='background:#1e293b'>%s%s%s%s%s%s%s%s%s%s%s</tr>" % (
|
||||
td("ASN", "#64748b", "right", mono=True),
|
||||
td("Name", "#64748b"),
|
||||
td("RIR", "#64748b", "center"),
|
||||
td("CC", "#64748b", "center"),
|
||||
td("Pfx", "#64748b", "right"),
|
||||
td("RIPE Stat", "#64748b", "right"),
|
||||
td("RPKI%", "#64748b", "right"),
|
||||
td("IX", "#64748b", "right"),
|
||||
td("PDB IX", "#64748b", "right"),
|
||||
td("Fac", "#64748b", "right"),
|
||||
td("Status", "#64748b", "center")))
|
||||
rows = ""
|
||||
for i, r in enumerate(sorted_results):
|
||||
d = r.get("data", {})
|
||||
bg = "#0f172a" if i % 2 == 0 else "#111827"
|
||||
st = r.get("status", "ok")
|
||||
if st == "critical":
|
||||
st_html = '<span style="color:#ef4444;font-weight:700">✗ CRIT</span>'
|
||||
elif st == "warning":
|
||||
st_html = '<span style="color:#f97316">⚠ WARN</span>'
|
||||
else:
|
||||
st_html = '<span style="color:#22c55e">✓</span>'
|
||||
pct = d.get("rpki_pct", 100)
|
||||
rpki_c = "#ef4444" if pct < 50 else "#f97316" if pct < 80 else "#94a3b8"
|
||||
ripe = str(d.get("ripe_stat_pfx", "—"))
|
||||
pdb = str(d.get("pdb_ix_cnt", "—"))
|
||||
rows += "<tr style='background:%s'>%s%s%s%s%s%s%s%s%s%s<td style='font-size:11px;padding:3px 8px;text-align:center'>%s</td></tr>" % (
|
||||
bg,
|
||||
td("AS" + str(r["asn"]), "#64748b", "right", mono=True),
|
||||
td((d.get("name") or "?")[:28], "#e2e8f0"),
|
||||
td(d.get("rir", "?"), "#94a3b8", "center"),
|
||||
td(d.get("cc", "?"), "#94a3b8", "center"),
|
||||
td(str(d.get("pfx_total", 0)), "#94a3b8", "right", mono=True),
|
||||
td(ripe, "#64748b", "right", mono=True),
|
||||
td(str(pct) + "%", rpki_c, "right", mono=True),
|
||||
td(str(d.get("ix_cnt", 0)), "#94a3b8", "right", mono=True),
|
||||
td(pdb, "#64748b", "right", mono=True),
|
||||
td(str(d.get("fac_cnt", 0)), "#94a3b8", "right", mono=True),
|
||||
st_html)
|
||||
|
||||
all_table = (
|
||||
"<h3 style='color:#94a3b8;font-size:12px;margin:24px 0 6px;text-transform:uppercase'>All Tested ASNs (%d)</h3>" % len(sorted_results) +
|
||||
"<div style='overflow-x:auto'><table style='width:100%%;border-collapse:collapse;border-radius:6px;overflow:hidden'>%s%s</table></div>" % (header, rows)
|
||||
)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body style="margin:0;padding:0;background:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||||
<div style="max-width:900px;margin:0 auto;padding:24px 16px">
|
||||
<div style="margin-bottom:20px">
|
||||
<span style="color:#3b82f6;font-weight:700;font-size:18px;letter-spacing:1px">PEER</span><span style="color:#fff;font-weight:700;font-size:18px;letter-spacing:1px">CORTEX</span>
|
||||
<span style="color:#475569;font-size:12px;margin-left:10px">Daily Audit Report · %(date)s</span>
|
||||
</div>
|
||||
<div style="background:%(sc)s18;border:1px solid %(sc)s40;border-radius:8px;padding:14px 18px;margin-bottom:20px">
|
||||
<div style="font-size:17px;font-weight:700;color:%(sc)s">%(emoji)s %(text)s</div>
|
||||
<div style="color:#94a3b8;font-size:12px;margin-top:4px">%(total)d ASNs tested from pool of %(pool)d · Runtime: %(mins)dm %(secs)ds · Cross-validated: RIPE Stat + PeeringDB</div>
|
||||
</div>
|
||||
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px"><tr>
|
||||
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
|
||||
<div style="font-size:32px;font-weight:700;color:#22c55e">%(ok)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">PERFECT</div></td>
|
||||
<td style="width:3%%"></td>
|
||||
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
|
||||
<div style="font-size:32px;font-weight:700;color:#f97316">%(warn)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">WARNINGS</div></td>
|
||||
<td style="width:3%%"></td>
|
||||
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
|
||||
<div style="font-size:32px;font-weight:700;color:#ef4444">%(crit)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">CRITICAL</div></td>
|
||||
<td style="width:3%%"></td>
|
||||
<td style="width:22%%;text-align:center;background:#1e293b;border-radius:8px;padding:16px 8px">
|
||||
<div style="font-size:32px;font-weight:700;color:#475569">%(skip)d</div><div style="color:#64748b;font-size:10px;margin-top:4px">SKIPPED</div></td>
|
||||
</tr></table>
|
||||
%(issues)s %(skipped)s %(rpki)s %(alltable)s
|
||||
<div style="margin-top:32px;padding-top:14px;border-top:1px solid #1e293b;color:#475569;font-size:11px">
|
||||
PeerCortex · <a href="https://peercortex.org" style="color:#3b82f6;text-decoration:none">peercortex.org</a>
|
||||
· 100 ASNs rotate daily from %(pool)d ASN pool · Audit at 02:00 UTC, report at 06:00 UTC
|
||||
</div>
|
||||
</div></body></html>""" % dict(
|
||||
date=date_str, sc=sc, emoji=emoji, text=text,
|
||||
total=total, pool=pool, mins=mins, secs=secs,
|
||||
ok=ok, warn=warn, crit=crit, skip=skip,
|
||||
issues=issues_html, skipped=skipped_html,
|
||||
rpki=rpki_html, alltable=all_table)
|
||||
|
||||
return html, subject
|
||||
|
||||
|
||||
def send_email(subject, html_body):
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = MAIL_FROM
|
||||
msg["To"] = MAIL_TO
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg["Message-ID"] = make_msgid(domain="fichtmueller.org")
|
||||
msg.attach(MIMEText("PeerCortex Daily Audit — please view in an HTML-capable email client.\n\nhttps://peercortex.org", "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as s:
|
||||
s.sendmail(MAIL_FROM, [MAIL_TO], msg.as_bytes())
|
||||
print("Sent '%s' -> %s" % (subject, MAIL_TO))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
report, date_str = load_report()
|
||||
html, subject = build(report, date_str)
|
||||
print("Email size: %d bytes" % len(html.encode()))
|
||||
send_email(subject, html)
|
||||
3
audit/send_audit_report.sh
Executable file
3
audit/send_audit_report.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export $(grep -E '^SMTP' /opt/peercortex-app/.env | xargs)
|
||||
python3 /opt/peercortex-app/audit/send_audit_report.py
|
||||
199
bgp-hijack-monitor.js
Normal file
199
bgp-hijack-monitor.js
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* BGP Hijack Monitor
|
||||
*
|
||||
* Background job that monitors own ASNs for unexpected BGP origin changes.
|
||||
* Runs periodically (every 6 hours via systemd timer) to detect potential hijacks.
|
||||
*
|
||||
* Usage:
|
||||
* node bgp-hijack-monitor.js
|
||||
*
|
||||
* Environment Variables:
|
||||
* - DB_HOST: PostgreSQL host (default: 192.168.178.82)
|
||||
* - DB_PORT: PostgreSQL port (default: 5432)
|
||||
* - DB_NAME: Database name (default: llm_gateway)
|
||||
* - DB_USER: Database user (default: llm)
|
||||
* - DB_PASSWORD: Database password (default: llm_secure_2026)
|
||||
* - OWN_ASNS: Comma-separated list of ASNs to monitor (e.g., "13335,15169,32787")
|
||||
* - FINDINGS_TABLE: Table to write findings to (default: findings)
|
||||
*/
|
||||
|
||||
const pg = require('pg');
|
||||
const pool = new pg.Pool({
|
||||
host: process.env.DB_HOST || '192.168.178.82',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'llm_gateway',
|
||||
user: process.env.DB_USER || 'llm',
|
||||
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
||||
max: 5,
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
const OWN_ASNS = (process.env.OWN_ASNS || '13335,15169').split(',').map(a => a.trim());
|
||||
const FINDINGS_TABLE = process.env.FINDINGS_TABLE || 'findings';
|
||||
|
||||
/**
|
||||
* Get all prefixes announced by an ASN from BGP routes table
|
||||
*/
|
||||
async function getBaselinePrefixes(asn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT DISTINCT prefix, origin_asn
|
||||
FROM bgp_routes
|
||||
WHERE origin_asn = $1
|
||||
LIMIT 1000
|
||||
`;
|
||||
const result = await client.query(query, [asn]);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current BGP routes for a set of prefixes
|
||||
*/
|
||||
async function getCurrentRoutes(prefixes) {
|
||||
if (prefixes.length === 0) return [];
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const placeholders = prefixes.map((_, i) => `$${i + 1}`).join(',');
|
||||
const query = `
|
||||
SELECT prefix, origin_asn
|
||||
FROM bgp_routes
|
||||
WHERE prefix = ANY(ARRAY[${placeholders}]::CIDR[])
|
||||
AND last_seen > NOW() - INTERVAL '1 hour'
|
||||
`;
|
||||
const result = await client.query(query, prefixes);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for hijacks: unexpected origin ASNs for known prefixes
|
||||
*/
|
||||
async function checkForHijacks(asn) {
|
||||
console.log(`[BGP Hijack Monitor] Checking ASN ${asn} for unexpected origin changes...`);
|
||||
|
||||
try {
|
||||
// Get baseline (expected) prefixes for this ASN
|
||||
const baseline = await getBaselinePrefixes(asn);
|
||||
if (baseline.length === 0) {
|
||||
console.log(`[BGP Hijack Monitor] No baseline prefixes found for ASN ${asn}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[BGP Hijack Monitor] Baseline: ${baseline.length} prefixes for AS${asn}`);
|
||||
|
||||
// Get current routes for these prefixes
|
||||
const prefixList = baseline.map(r => r.prefix);
|
||||
const current = await getCurrentRoutes(prefixList);
|
||||
|
||||
// Group current routes by prefix
|
||||
const currentByPrefix = {};
|
||||
current.forEach(r => {
|
||||
if (!currentByPrefix[r.prefix]) {
|
||||
currentByPrefix[r.prefix] = [];
|
||||
}
|
||||
currentByPrefix[r.prefix].push(r.origin_asn);
|
||||
});
|
||||
|
||||
// Compare: detect unexpected origin ASNs
|
||||
const hijackCandidates = [];
|
||||
baseline.forEach(expectedRoute => {
|
||||
const prefix = expectedRoute.prefix;
|
||||
const expectedAsn = expectedRoute.origin_asn;
|
||||
|
||||
if (currentByPrefix[prefix]) {
|
||||
const currentAsns = currentByPrefix[prefix];
|
||||
|
||||
// If the expected ASN is NOT in the current set of origin ASNs, it's a hijack
|
||||
if (!currentAsns.includes(expectedAsn)) {
|
||||
hijackCandidates.push({
|
||||
prefix,
|
||||
expectedAsn,
|
||||
foundAsns: currentAsns,
|
||||
});
|
||||
}
|
||||
|
||||
// If there are MULTIPLE origin ASNs (not just the expected one), it's also suspicious (MOAS)
|
||||
if (currentAsns.length > 1) {
|
||||
hijackCandidates.push({
|
||||
prefix,
|
||||
expectedAsn,
|
||||
foundAsns: currentAsns,
|
||||
type: 'MOAS', // Multiple Origin ASNs
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hijackCandidates.length > 0) {
|
||||
console.log(`[BGP Hijack Monitor] ⚠️ DETECTED ${hijackCandidates.length} hijack candidates`);
|
||||
|
||||
// Log each finding to the findings table
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
for (const hijack of hijackCandidates) {
|
||||
const description = hijack.type === 'MOAS'
|
||||
? `MOAS detected: Multiple origin ASNs (${hijack.foundAsns.join(', ')}) announcing ${hijack.prefix}`
|
||||
: `BGP Hijack: AS${hijack.expectedAsn} expected but AS${hijack.foundAsns.join(', AS')} found announcing ${hijack.prefix}`;
|
||||
|
||||
const findingQuery = `
|
||||
INSERT INTO ${FINDINGS_TABLE}
|
||||
(timestamp, source, severity, asn, description, details)
|
||||
VALUES (NOW(), 'bgp_hijack_monitor', 'HIGH', $1, $2, $3)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
const details = JSON.stringify({
|
||||
prefix: hijack.prefix,
|
||||
expected_asn: hijack.expectedAsn,
|
||||
found_asns: hijack.foundAsns,
|
||||
type: hijack.type || 'HIJACK',
|
||||
detected_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await client.query(findingQuery, [asn, description, details]);
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} else {
|
||||
console.log(`[BGP Hijack Monitor] ✓ No hijacks detected for AS${asn}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[BGP Hijack Monitor] Error checking ASN ${asn}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main: Check all monitored ASNs
|
||||
*/
|
||||
async function main() {
|
||||
console.log(`[BGP Hijack Monitor] Starting hijack detection for ASNs: ${OWN_ASNS.join(', ')}`);
|
||||
|
||||
for (const asn of OWN_ASNS) {
|
||||
await checkForHijacks(asn);
|
||||
}
|
||||
|
||||
console.log('[BGP Hijack Monitor] Hijack detection complete');
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Error handling
|
||||
process.on('error', (err) => {
|
||||
console.error('[BGP Hijack Monitor] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[BGP Hijack Monitor] Main loop error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
71
deploy-from-git.sh
Executable file
71
deploy-from-git.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Git-pull-based deploy for Erik. Replaces the ad-hoc per-file `cat file | ssh`
|
||||
# uploads used on 2026-07-16, which twice missed files that server.js actually
|
||||
# requires (src/backend/config.js, src/backend/services/smtp.js) because
|
||||
# uploading was a manual, easy-to-forget step instead of a single `git pull`.
|
||||
#
|
||||
# Run this ON Erik, from /opt/peercortex-app. Deliberately manual (not wired
|
||||
# to a Gitea Action) -- Erik hosts many unrelated production services and
|
||||
# main can contain code that hasn't been live-tested yet (see project memory).
|
||||
#
|
||||
# Usage: ./deploy-from-git.sh [branch] (default: main)
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BRANCH="${1:-main}"
|
||||
BACKUP_DIR=/opt/_rollbacks/peercortex-app
|
||||
TS=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "[1/7] Backing up current server.js + public/index.html..."
|
||||
cp -p server.js "$BACKUP_DIR/server.js.$TS"
|
||||
cp -p public/index.html "$BACKUP_DIR/index.html.$TS"
|
||||
|
||||
echo "[2/7] Fetching + checking out $BRANCH..."
|
||||
git fetch origin
|
||||
git checkout "$BRANCH"
|
||||
git pull origin "$BRANCH"
|
||||
|
||||
echo "[3/7] npm ci..."
|
||||
npm ci
|
||||
|
||||
echo "[4/7] Verifying every local require() target actually exists..."
|
||||
MISSING=0
|
||||
for f in server.js local-db-client.js bio-rd-client.js; do
|
||||
[ -f "$f" ] || continue
|
||||
for req in $(grep -oE "require\(['\"]\./[^'\"]+['\"]\)" "$f" | sed -E "s/require\(['\"]\.\/([^'\"]+)['\"]\)/\1/"); do
|
||||
if [ ! -f "$req" ] && [ ! -f "$req.js" ] && [ ! -d "$req" ]; then
|
||||
echo " MISSING: $f requires ./$req -- not found" >&2
|
||||
MISSING=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [ "$MISSING" = "1" ]; then
|
||||
echo "Aborting: fix the missing file(s) above before deploying." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[5/7] Syntax check..."
|
||||
node --check server.js
|
||||
node --check local-db-client.js
|
||||
|
||||
echo "[6/7] Building dist/ (TypeScript API server)..."
|
||||
# tsc exits non-zero on pre-existing src/mcp-server/* errors unrelated to this
|
||||
# deploy, but still emits working output (default noEmitOnError:false) for
|
||||
# files that DID typecheck cleanly -- confirmed 2026-07-16. Don't let `set -e`
|
||||
# abort here; check the files this deploy actually needs instead.
|
||||
npm run build || true
|
||||
for f in dist/api/index.js dist/api/server.js dist/aspa/validator.js; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "Aborting: required build output missing: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[7/7] Restarting peercortex (and peercortex-api if configured)..."
|
||||
pm2 restart peercortex --update-env
|
||||
pm2 restart peercortex-api --update-env 2>/dev/null || echo " (peercortex-api not running yet, skipped)"
|
||||
|
||||
echo ""
|
||||
echo "Done. Rollback: cp $BACKUP_DIR/server.js.$TS server.js && cp $BACKUP_DIR/index.html.$TS public/index.html && pm2 restart peercortex"
|
||||
9
deploy-from-scp.sh
Executable file
9
deploy-from-scp.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Deploy PeerCortex from pre-copied files (server.js already in place via SCP)
|
||||
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
|
||||
ls -t $BACKUP_DIR/server.js.* 2>/dev/null | tail -n +21 | xargs rm -f 2>/dev/null
|
||||
echo "[backup] Saved server.js ($TS)"
|
||||
pm2 restart peercortex
|
||||
@ -1,5 +1,10 @@
|
||||
#!/bin/bash
|
||||
# PeerCortex safe deploy script — always backup before restart
|
||||
# NOTE: this only covers server.js + public/index.html (the main site,
|
||||
# PM2 process "peercortex"). The Fastify API server (PM2 process
|
||||
# "peercortex-api", dist/api/index.js, port 3102) needs `npm run build`
|
||||
# run first and `pm2 restart peercortex-api` separately -- see
|
||||
# ecosystem.config.js on Erik for both app definitions.
|
||||
BACKUP_DIR=/opt/peercortex-app/backups
|
||||
mkdir -p $BACKUP_DIR
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex - Network Intelligence Dashboard</title>
|
||||
<script defer src="https://analytics.fichtmueller.org/script.js" data-website-id="1cdd1e46-37f8-47c3-9b7f-a3992a46f5ed"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
@ -510,7 +511,7 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,158,100,.12);border:1px solid rgba(255,158,100,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔐</div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange)">ASPA (RFC 9582)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">AS Provider Authorization from Cloudflare RPKI JSON feed. RFC-compliant upstream/downstream path verification with valley detection.</div><a href="https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-verification-14.html" target="_blank" style="font-size:.65rem;color:var(--blue)">IETF Draft-14</a></div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange)">ASPA (IETF draft, not yet an RFC)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">AS Provider Authorization from Cloudflare RPKI JSON feed. Draft-compliant upstream/downstream path verification with valley detection.</div><a href="https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/" target="_blank" style="font-size:.65rem;color:var(--blue)">IETF Draft</a></div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
|
||||
40124
hijack-alerts.json
Normal file
40124
hijack-alerts.json
Normal file
File diff suppressed because it is too large
Load Diff
1
lama2/01_search_asn.l2
Normal file
1
lama2/01_search_asn.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/search?q=AS13335
|
||||
1
lama2/02_search_prefix.l2
Normal file
1
lama2/02_search_prefix.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/search?q=1.1.1.0%2F24
|
||||
1
lama2/03_irr_audit.l2
Normal file
1
lama2/03_irr_audit.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/irr-audit?asn=13335
|
||||
1
lama2/04_rpki_history.l2
Normal file
1
lama2/04_rpki_history.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/rpki-history?prefix=1.1.1.0%2F24
|
||||
1
lama2/05_aspath.l2
Normal file
1
lama2/05_aspath.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/aspath?asn=13335
|
||||
1
lama2/06_looking_glass.l2
Normal file
1
lama2/06_looking_glass.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/looking-glass?asn=13335
|
||||
1
lama2/07_ix_matrix.l2
Normal file
1
lama2/07_ix_matrix.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/ix-matrix?asn=13335
|
||||
1
lama2/08_hijack_alerts.l2
Normal file
1
lama2/08_hijack_alerts.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/hijack-alerts?asn=13335
|
||||
1
lama2/09_rib_routers.l2
Normal file
1
lama2/09_rib_routers.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/rib/routers
|
||||
1
lama2/10_prefix_changes.l2
Normal file
1
lama2/10_prefix_changes.l2
Normal file
@ -0,0 +1 @@
|
||||
GET https://peercortex.org/api/prefix-changes?prefix=1.1.1.0%2F24
|
||||
444
local-db-client.js
Normal file
444
local-db-client.js
Normal file
@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Local Database Client for PeerCortex
|
||||
* Replaces external API calls with local PostgreSQL queries
|
||||
* BGP + RPKI + Threat Intel + RDAP caching
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER || 'llm',
|
||||
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
||||
host: process.env.DB_HOST || '192.168.178.82',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'llm_gateway',
|
||||
});
|
||||
|
||||
// RDAP Cache (in-memory for this session)
|
||||
const rdapCache = new Map();
|
||||
const RDAP_CACHE_TTL = 3600000; // 1 hour
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// BGP FUNCTIONS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
async function getBgpStatus(prefix) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT origin_asn, MAX(visibility_percent) as visibility_percent, MAX(last_seen) as last_seen
|
||||
FROM bgp_routes
|
||||
WHERE prefix = $1::cidr
|
||||
GROUP BY origin_asn`,
|
||||
[prefix]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return {
|
||||
announced: false,
|
||||
origin_asns: [],
|
||||
visibility_percent: 0,
|
||||
last_seen: new Date().toISOString(),
|
||||
source: 'local_bgp',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
announced: true,
|
||||
origin_asns: result.rows.map(r => r.origin_asn),
|
||||
visibility_percent: Math.max(...result.rows.map(r => parseFloat(r.visibility_percent) || 0)),
|
||||
last_seen: result.rows[0].last_seen || new Date().toISOString(),
|
||||
source: 'local_bgp',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local DB] BGP Status Error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAnnouncedPrefixes(asn) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT prefix, origin_asn, visibility_percent, last_seen
|
||||
FROM bgp_routes
|
||||
WHERE origin_asn = $1
|
||||
ORDER BY visibility_percent DESC
|
||||
LIMIT 100`,
|
||||
[asn]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
console.error('[Local DB] Announced Prefixes Error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Returns null on DB error (check could not run) vs [] for a genuine single/no-origin
|
||||
// result -- a caller treating both as "no hijack" would silently disable hijack
|
||||
// detection on every transient DB hiccup while still showing the check as clean.
|
||||
async function checkBgpHijack(prefix) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT origin_asn FROM bgp_routes WHERE prefix = $1::cidr`,
|
||||
[prefix]
|
||||
);
|
||||
return result.rows.length > 1 ? result.rows.map(r => r.origin_asn) : [];
|
||||
} catch (error) {
|
||||
console.error('[Local DB] Hijack Check Error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// RPKI FUNCTIONS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
async function validateRpki(prefix, originAsn) {
|
||||
try {
|
||||
const prefixParts = prefix.split('/');
|
||||
if (prefixParts.length !== 2) {
|
||||
return { status: 'unknown', description: 'Invalid CIDR format' };
|
||||
}
|
||||
|
||||
const prefixLength = parseInt(prefixParts[1]);
|
||||
|
||||
// Query for covering ROAs. `prefix` is already a `cidr` column (its own
|
||||
// mask, e.g. "1.1.1.0/24") -- appending "/" + max_length a second time
|
||||
// produced invalid CIDR literals like "1.1.1.0/24/24" and made this
|
||||
// query fail for every prefix. max_length is a separate upper bound,
|
||||
// checked below against prefixLength once we have a covering ROA.
|
||||
// Uses <<= (contained-by-or-equal), not << (strictly contained) -- a
|
||||
// route announced at exactly the ROA's own prefix length is the most
|
||||
// common real-world case and << alone misses it entirely.
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM rpki_roas
|
||||
WHERE $1::cidr <<= prefix
|
||||
AND origin_asn = $2
|
||||
AND expires > NOW()
|
||||
LIMIT 10`,
|
||||
[prefix, originAsn]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
const anyRoa = await pool.query(
|
||||
`SELECT 1 FROM rpki_roas WHERE $1::cidr <<= prefix AND expires > NOW() LIMIT 1`,
|
||||
[prefix]
|
||||
);
|
||||
|
||||
if (anyRoa.rows.length > 0) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
prefix,
|
||||
asn: originAsn,
|
||||
description: `RPKI INVALID: ROAs exist but origin ASN ${originAsn} not authorized`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'not-found',
|
||||
prefix,
|
||||
asn: originAsn,
|
||||
description: 'No matching ROA found (unprotected)',
|
||||
};
|
||||
}
|
||||
|
||||
const roa = result.rows[0];
|
||||
if (prefixLength > roa.max_length) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
prefix,
|
||||
asn: originAsn,
|
||||
max_length: roa.max_length,
|
||||
description: `RPKI INVALID: Prefix length ${prefixLength} > max_length ${roa.max_length}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'valid',
|
||||
prefix,
|
||||
asn: originAsn,
|
||||
max_length: roa.max_length,
|
||||
expires: roa.expires,
|
||||
description: `RPKI VALID: Origin ASN ${originAsn} authorized`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local DB] RPKI Validation Error:', error.message);
|
||||
return { status: 'unknown', description: 'RPKI validation error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoasForAsn(asn) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT prefix, max_length, expires FROM rpki_roas
|
||||
WHERE origin_asn = $1 AND expires > NOW()
|
||||
ORDER BY prefix`,
|
||||
[asn]
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
console.error('[Local DB] ROAs for ASN Error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// THREAT INTEL FUNCTIONS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
async function getThreatIntel(ip) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT ip_address, threat_level, confidence_score, source, details, cached_at
|
||||
FROM threat_intel
|
||||
WHERE ip_address = $1::inet
|
||||
AND expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[ip]
|
||||
);
|
||||
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
} catch (error) {
|
||||
console.error('[Local DB] Threat Intel Error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isMaliciousIp(ip) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT 1 FROM threat_intel
|
||||
WHERE ip_address = $1::inet
|
||||
AND threat_level IN ('CRITICAL', 'HIGH')
|
||||
AND expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[ip]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
} catch (error) {
|
||||
console.error('[Local DB] Malicious IP Check Error:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// RDAP CACHING (in-memory)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
function getRdapCached(resource) {
|
||||
const cached = rdapCache.get(resource);
|
||||
if (cached && Date.now() - cached.timestamp < RDAP_CACHE_TTL) {
|
||||
console.log(`[RDAP Cache] HIT: ${resource}`);
|
||||
return cached.data;
|
||||
}
|
||||
if (cached) rdapCache.delete(resource);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setRdapCached(resource, data) {
|
||||
rdapCache.set(resource, { data, timestamp: Date.now() });
|
||||
console.log(`[RDAP Cache] SET: ${resource} (TTL: 1h)`);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// RIPE STAT API WRAPPER (Drop-in replacements for external API calls)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
async function getRipeStatAnnouncedPrefixes(asn) {
|
||||
try {
|
||||
const prefixes = await getAnnouncedPrefixes(asn);
|
||||
return {
|
||||
status: 'ok',
|
||||
cached: false,
|
||||
data: {
|
||||
resource: `AS${asn}`,
|
||||
prefixes: prefixes.map(p => ({
|
||||
prefix: p.prefix,
|
||||
origin_asn: p.origin_asn,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] Announced Prefixes Error:', error.message);
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, prefixes: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRipeStatAsnNeighbours(asn) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT origin_asn FROM bgp_routes LIMIT 200`
|
||||
);
|
||||
|
||||
const neighbours = result.rows.map(r => ({
|
||||
asn: r.origin_asn,
|
||||
type: 'unknown',
|
||||
prefixes: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
cached: false,
|
||||
data: {
|
||||
resource: `AS${asn}`,
|
||||
neighbours: neighbours,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] ASN Neighbours Error:', error.message);
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, neighbours: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRipeStatAsOverview(asn) {
|
||||
try {
|
||||
const prefixes = await getAnnouncedPrefixes(asn);
|
||||
const roasCount = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM rpki_roas WHERE origin_asn = $1 AND expires > NOW()`,
|
||||
[asn]
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
cached: false,
|
||||
data: {
|
||||
resource: `AS${asn}`,
|
||||
asn: asn,
|
||||
holder: 'Unknown',
|
||||
announced_prefixes_count: prefixes.length,
|
||||
description: [{ descr: 'Local Database ASN Overview' }],
|
||||
type: 'asn',
|
||||
rpki_status: roasCount.rows[0].count > 0 ? 'signed' : 'not-signed',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] AS Overview Error:', error.message);
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, announced_prefixes_count: 0 } };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRipeStatVisibility(asn) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT COALESCE(AVG(visibility_percent), 0) as avg_visibility
|
||||
FROM bgp_routes
|
||||
WHERE origin_asn = $1`,
|
||||
[asn]
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
cached: false,
|
||||
data: {
|
||||
resource: `AS${asn}`,
|
||||
visibility: {
|
||||
ipv4: {
|
||||
ris_peers_seeing: Math.round(result.rows[0].avg_visibility),
|
||||
total_ris_peers: 100,
|
||||
sees_ris_peers: Math.round(result.rows[0].avg_visibility),
|
||||
},
|
||||
ipv6: {
|
||||
ris_peers_seeing: 0,
|
||||
total_ris_peers: 100,
|
||||
sees_ris_peers: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] Visibility Error:', error.message);
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, visibility: {} } };
|
||||
}
|
||||
}
|
||||
|
||||
async function getRipeStatPrefixSizeDistribution(asn) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT masklen(prefix) as prefix_len
|
||||
FROM bgp_routes
|
||||
WHERE origin_asn = $1
|
||||
ORDER BY masklen(prefix)`,
|
||||
[asn]
|
||||
);
|
||||
|
||||
const distribution = {};
|
||||
result.rows.forEach(row => {
|
||||
const len = row.prefix_len;
|
||||
distribution[len] = (distribution[len] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
cached: false,
|
||||
data: {
|
||||
resource: `AS${asn}`,
|
||||
ipv4_prefix_size: Object.keys(distribution)
|
||||
.map(len => ({ prefix_length: parseInt(len), count: distribution[len] }))
|
||||
.sort((a, b) => a.prefix_length - b.prefix_length),
|
||||
ipv6_prefix_size: [],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local RIPE Stat] Prefix Size Distribution Error:', error.message);
|
||||
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, ipv4_prefix_size: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// STATS & HEALTH CHECK
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
async function getLocalDbStats() {
|
||||
try {
|
||||
const bgp = await pool.query(`SELECT COUNT(*) as count FROM bgp_routes`);
|
||||
const rpki = await pool.query(`SELECT COUNT(*) as count FROM rpki_roas WHERE expires > NOW()`);
|
||||
const threat = await pool.query(`SELECT COUNT(*) as count FROM threat_intel WHERE expires_at > NOW()`);
|
||||
|
||||
return {
|
||||
bgp_routes: parseInt(bgp.rows[0].count),
|
||||
rpki_roas: parseInt(rpki.rows[0].count),
|
||||
threat_intel: parseInt(threat.rows[0].count),
|
||||
rdap_cache_entries: rdapCache.size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Local DB] Stats Error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// EXPORTS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
module.exports = {
|
||||
// BGP
|
||||
getBgpStatus,
|
||||
getAnnouncedPrefixes,
|
||||
checkBgpHijack,
|
||||
|
||||
// RPKI
|
||||
validateRpki,
|
||||
getRoasForAsn,
|
||||
|
||||
// Threat Intel
|
||||
getThreatIntel,
|
||||
isMaliciousIp,
|
||||
|
||||
// RIPE Stat API Wrappers
|
||||
getRipeStatAnnouncedPrefixes,
|
||||
getRipeStatAsnNeighbours,
|
||||
getRipeStatAsOverview,
|
||||
getRipeStatVisibility,
|
||||
getRipeStatPrefixSizeDistribution,
|
||||
|
||||
// RDAP Cache
|
||||
getRdapCached,
|
||||
setRdapCached,
|
||||
|
||||
// Health
|
||||
getLocalDbStats,
|
||||
cleanup,
|
||||
};
|
||||
273
magatama-s2ten-bgp-enrichment.js
Normal file
273
magatama-s2ten-bgp-enrichment.js
Normal file
@ -0,0 +1,273 @@
|
||||
/**
|
||||
* MAGATAMA S2 TEN BGP Enrichment Task
|
||||
*
|
||||
* Enriches MAGATAMA S2 TEN (Cloud) findings with:
|
||||
* 1. BGP status (is the IP/prefix announced?)
|
||||
* 2. RPKI validity (is the announcing ASN authorized?)
|
||||
* 3. Threat intelligence (known malicious IP or ASN?)
|
||||
*
|
||||
* This bridges PeerCortex local intelligence to MAGATAMA security findings.
|
||||
* Called when S2 TEN detects anomalous external traffic.
|
||||
*/
|
||||
|
||||
const pg = require('pg');
|
||||
|
||||
// Initialize PostgreSQL connection pool
|
||||
const pool = new pg.Pool({
|
||||
host: process.env.DB_HOST || '192.168.178.82',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'llm_gateway',
|
||||
user: process.env.DB_USER || 'llm',
|
||||
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 10000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get BGP status for an IP address
|
||||
*/
|
||||
async function getBgpStatus(ipAddress) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
prefix,
|
||||
origin_asn,
|
||||
ARRAY_AGG(DISTINCT origin_asn) as origin_asns,
|
||||
visibility_percent,
|
||||
last_seen
|
||||
FROM bgp_routes
|
||||
WHERE prefix >> $1::inet
|
||||
ORDER BY prefix DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await client.query(query, [ipAddress]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate RPKI for a prefix + origin ASN pair
|
||||
*/
|
||||
async function validateRpki(prefix, originAsn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
status,
|
||||
details
|
||||
FROM rpki_roas
|
||||
WHERE prefix >> $1::inet
|
||||
AND origin_asn = $2
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await client.query(query, [prefix, originAsn]);
|
||||
if (result.rows.length > 0) {
|
||||
return {
|
||||
status: result.rows[0].status || 'valid',
|
||||
details: result.rows[0].details,
|
||||
};
|
||||
}
|
||||
// No ROA found = not-found
|
||||
return { status: 'not-found' };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat intelligence for an IP address
|
||||
*/
|
||||
async function getThreatIntel(ipAddress) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
ip_address,
|
||||
threat_level,
|
||||
confidence_score,
|
||||
source,
|
||||
cached_at
|
||||
FROM threat_intel
|
||||
WHERE ip_address = $1::inet
|
||||
LIMIT 1
|
||||
`;
|
||||
const result = await client.query(query, [ipAddress]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine kill-chain phase based on BGP + RPKI + Threat signals
|
||||
*/
|
||||
function determineKillChainPhase(enriched) {
|
||||
const flags = enriched.security_flags || [];
|
||||
|
||||
if (flags.some(f => f.flag === 'RPKI_INVALID')) {
|
||||
return 'WEAPONIZATION'; // BGP hijack = active attack infrastructure setup
|
||||
}
|
||||
|
||||
if (flags.some(f => f.flag === 'MALICIOUS_IP')) {
|
||||
return 'DELIVERY'; // Known malicious IP attempting connection
|
||||
}
|
||||
|
||||
if (enriched.bgp_intel.announced === false) {
|
||||
return 'EXPLOITATION'; // Unknown IP not in routing table = potentially spoofed
|
||||
}
|
||||
|
||||
return 'RECONNAISSANCE'; // Announced legitimate IP but with anomalous traffic pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* Map to MITRE ATT&CK framework
|
||||
*/
|
||||
function mapToMitreAttack(enriched) {
|
||||
const mapping = [];
|
||||
|
||||
if (enriched.bgp_intel.rpki_valid === false) {
|
||||
mapping.push({
|
||||
technique: 'T1040', // Network Sniffing (implicit in BGP hijack)
|
||||
tactic: 'Credential Access / Collection',
|
||||
description: 'BGP RPKI validation failed - possible route hijack',
|
||||
});
|
||||
}
|
||||
|
||||
if (enriched.threat_intel?.threat_level === 'CRITICAL') {
|
||||
mapping.push({
|
||||
technique: 'T1566', // Phishing (network variant)
|
||||
tactic: 'Initial Access',
|
||||
description: 'Connection from known malicious IP',
|
||||
});
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a finding with BGP + RPKI + Threat data
|
||||
* @param {Object} finding - MAGATAMA finding { ip_address, port, protocol, ... }
|
||||
* @returns {Object} Enriched finding with bgp_status, rpki_valid, threat_level
|
||||
*/
|
||||
async function enrichFindingWithBGPIntel(finding) {
|
||||
const enriched = { ...finding, bgp_intel: {} };
|
||||
const ipAddress = finding.ip_address;
|
||||
|
||||
if (!ipAddress) {
|
||||
console.log('[S2TEN Enrichment] No IP address in finding, skipping enrichment');
|
||||
return enriched;
|
||||
}
|
||||
|
||||
try {
|
||||
// ---- Step 1: BGP Status Lookup ----
|
||||
// Check if this IP is part of an announced prefix in our local BGP routes
|
||||
const bgpStatus = await getBgpStatus(ipAddress);
|
||||
if (bgpStatus) {
|
||||
enriched.bgp_intel.announced = true;
|
||||
enriched.bgp_intel.prefix = bgpStatus.prefix;
|
||||
enriched.bgp_intel.origin_asn = bgpStatus.origin_asns ? bgpStatus.origin_asns[0] : null;
|
||||
enriched.bgp_intel.origin_asns = bgpStatus.origin_asns || [];
|
||||
enriched.bgp_intel.visibility_percent = bgpStatus.visibility_percent;
|
||||
enriched.bgp_intel.last_seen = bgpStatus.last_seen;
|
||||
|
||||
// ---- Step 2: RPKI Validity Check ----
|
||||
// If we know the origin ASN, validate RPKI
|
||||
if (enriched.bgp_intel.origin_asn) {
|
||||
try {
|
||||
const rpkiResult = await validateRpki(
|
||||
bgpStatus.prefix || ipAddress,
|
||||
enriched.bgp_intel.origin_asn
|
||||
);
|
||||
enriched.bgp_intel.rpki_status = rpkiResult.status;
|
||||
enriched.bgp_intel.rpki_valid = rpkiResult.status === 'valid';
|
||||
|
||||
// Alert if RPKI is INVALID (potential hijack!)
|
||||
if (rpkiResult.status === 'invalid') {
|
||||
enriched.security_flags = enriched.security_flags || [];
|
||||
enriched.security_flags.push({
|
||||
flag: 'RPKI_INVALID',
|
||||
severity: 'CRITICAL',
|
||||
message: `RPKI validation failed: AS${enriched.bgp_intel.origin_asn} is not authorized to announce this prefix`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[S2TEN] RPKI check failed for ${ipAddress}:`, e.message);
|
||||
enriched.bgp_intel.rpki_status = 'unknown';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enriched.bgp_intel.announced = false;
|
||||
enriched.bgp_intel.message = 'IP not found in BGP routing table';
|
||||
}
|
||||
|
||||
// ---- Step 3: Threat Intelligence Lookup ----
|
||||
// Check if this IP or its origin ASN is in threat_intel table
|
||||
const threatIntel = await getThreatIntel(ipAddress);
|
||||
if (threatIntel) {
|
||||
enriched.threat_intel = {
|
||||
ip_address: threatIntel.ip_address,
|
||||
threat_level: threatIntel.threat_level,
|
||||
confidence_score: threatIntel.confidence_score,
|
||||
source: threatIntel.source,
|
||||
cached_at: threatIntel.cached_at,
|
||||
};
|
||||
|
||||
// Alert if threat level is HIGH or CRITICAL
|
||||
if (['HIGH', 'CRITICAL'].includes(threatIntel.threat_level)) {
|
||||
enriched.security_flags = enriched.security_flags || [];
|
||||
enriched.security_flags.push({
|
||||
flag: 'MALICIOUS_IP',
|
||||
severity: threatIntel.threat_level,
|
||||
message: `Known malicious IP: threat level ${threatIntel.threat_level} (confidence: ${threatIntel.confidence_score}%)`,
|
||||
source: threatIntel.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step 4: MAGATAMA Kill-Chain Correlation ----
|
||||
// Map BGP + RPKI + Threat findings to MITRE ATT&CK kill chain
|
||||
enriched.kill_chain_phase = determineKillChainPhase(enriched);
|
||||
enriched.mitre_attack_mapping = mapToMitreAttack(enriched);
|
||||
|
||||
return enriched;
|
||||
} catch (e) {
|
||||
console.error(`[S2TEN Enrichment] Fatal error enriching ${ipAddress}:`, e.message);
|
||||
enriched.enrichment_error = e.message;
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch enrich multiple findings
|
||||
*/
|
||||
async function enrichFindings(findings) {
|
||||
console.log(`[S2TEN Enrichment] Enriching ${findings.length} findings...`);
|
||||
const enriched = [];
|
||||
|
||||
for (const finding of findings) {
|
||||
try {
|
||||
const result = await enrichFindingWithBGPIntel(finding);
|
||||
enriched.push(result);
|
||||
} catch (e) {
|
||||
console.error(`[S2TEN Enrichment] Error enriching finding:`, e.message);
|
||||
enriched.push({ ...finding, enrichment_error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enrichFindingWithBGPIntel,
|
||||
enrichFindings,
|
||||
determineKillChainPhase,
|
||||
mapToMitreAttack,
|
||||
pool,
|
||||
getBgpStatus,
|
||||
validateRpki,
|
||||
getThreatIntel,
|
||||
};
|
||||
1308
package-lock.json
generated
1308
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "peercortex",
|
||||
"version": "0.6.5",
|
||||
"version": "0.7.1",
|
||||
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
||||
"main": "dist/mcp-server/index.js",
|
||||
"types": "dist/mcp-server/index.d.ts",
|
||||
@ -53,10 +53,10 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/renefichtmueller/PeerCortex.git"
|
||||
"url": "https://gitea.context-x.org/rene/PeerCortex.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/renefichtmueller/PeerCortex/issues"
|
||||
"url": "https://gitea.context-x.org/rene/PeerCortex/issues"
|
||||
},
|
||||
"homepage": "https://peercortex.org",
|
||||
"engines": {
|
||||
@ -66,20 +66,28 @@
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"node-whois": "^2.1.3",
|
||||
"fastify": "^5.8.5",
|
||||
"joi": "^17.11.0",
|
||||
"node-cron": "^4.6.0",
|
||||
"ollama": "^0.5.12",
|
||||
"pg": "^8.11.0",
|
||||
"playwright": "^1.40.0",
|
||||
"puppeteer": "^24.42.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"eslint": "^9.16.0",
|
||||
"nock": "^13.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
||||
502
public/aspa-adoption.html
Normal file
502
public/aspa-adoption.html
Normal file
@ -0,0 +1,502 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ASPA Adoption Tracker - PeerCortex</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.change-up {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.change-down {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.region-table,
|
||||
.ixp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.region-table th,
|
||||
.ixp-table th {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.region-table td,
|
||||
.ixp-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.region-table tr:hover,
|
||||
.ixp-table tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.coverage-bar {
|
||||
background: #e5e7eb;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coverage-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.forecast-box {
|
||||
background: #f0f9ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.forecast-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.forecast-label {
|
||||
font-size: 12px;
|
||||
color: #1e40af;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
display: inline-block;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🌐 ASPA Adoption Tracker</h1>
|
||||
<p class="subtitle">Global BGP ASPA (Autonomous System Provider Authorization) adoption trends</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Current Coverage</div>
|
||||
<div class="stat-value" id="current-coverage">-</div>
|
||||
<div class="stat-change">
|
||||
<span id="change-24h" class="change-up">+0.5%</span> (24h)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Trend</div>
|
||||
<div class="stat-value" id="trend-indicator">↑</div>
|
||||
<div class="stat-change" id="trend-strength">Moderate growth</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">6-Month Forecast</div>
|
||||
<div class="stat-value" id="forecast-coverage">-</div>
|
||||
<div class="stat-change">
|
||||
<span id="forecast-confidence">-</span> confidence
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Data Points</div>
|
||||
<div class="stat-value" id="data-points">-</div>
|
||||
<div class="stat-change" id="last-update">Updated -</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">📈 Adoption Trend (30 days)</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="adoption-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">🗺️ Regional Coverage</div>
|
||||
<table class="region-table" id="regions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Region</th>
|
||||
<th>Coverage</th>
|
||||
<th>ASNs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="regions-body">
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: #999">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">🏢 IXP Coverage</div>
|
||||
<table class="ixp-table" id="ixps-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IXP</th>
|
||||
<th>Coverage</th>
|
||||
<th>Participants</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ixps-body">
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: #999">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<div class="chart-title">🎯 Top Adopters</div>
|
||||
<div style="margin-top: 15px" id="top-adopters-list">
|
||||
<p style="color: #999">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Last updated: <span id="footer-timestamp">-</span></p>
|
||||
<p style="margin-top: 10px; color: #999">Data sources: RIPE Stat, PeeringDB, CAIDA. Updated daily at 2:00 AM UTC.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let adoptionChart = null
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const response = await fetch('/api/aspa-adoption-stats?period=30d')
|
||||
if (!response.ok) throw new Error('Failed to load data')
|
||||
const stats = await response.json()
|
||||
|
||||
updateStats(stats)
|
||||
renderAdoptionChart(stats.trend)
|
||||
loadRegionalData()
|
||||
loadIXPData()
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
document.body.innerHTML += `<div class="error">Failed to load ASPA adoption data</div>`
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(stats) {
|
||||
document.getElementById('current-coverage').textContent = stats.current.coverage.toFixed(1) + '%'
|
||||
document.getElementById('change-24h').textContent =
|
||||
(stats.current.change24h >= 0 ? '+' : '') + stats.current.change24h.toFixed(2) + '%'
|
||||
document.getElementById('change-24h').className = stats.current.change24h >= 0 ? 'change-up' : 'change-down'
|
||||
|
||||
const trendSymbols = { up: '↑', down: '↓', stable: '→' }
|
||||
document.getElementById('trend-indicator').textContent = trendSymbols[stats.current.trend] || '→'
|
||||
document.getElementById('trend-strength').textContent =
|
||||
stats.current.trend.charAt(0).toUpperCase() + stats.current.trend.slice(1) + ' trend'
|
||||
|
||||
document.getElementById('forecast-coverage').textContent = stats.forecast.predictedCoverage6m.toFixed(1) + '%'
|
||||
document.getElementById('forecast-confidence').textContent = (stats.forecast.confidence * 100).toFixed(0) + '%'
|
||||
|
||||
document.getElementById('data-points').textContent = stats.trend.length
|
||||
const lastUpdate = new Date()
|
||||
document.getElementById('last-update').textContent = 'Updated ' + formatTime(lastUpdate)
|
||||
document.getElementById('footer-timestamp').textContent = lastUpdate.toLocaleString()
|
||||
|
||||
renderTopAdopters(stats.topAdopters)
|
||||
}
|
||||
|
||||
function renderAdoptionChart(trendData) {
|
||||
const ctx = document.getElementById('adoption-chart').getContext('2d')
|
||||
|
||||
if (adoptionChart) {
|
||||
adoptionChart.destroy()
|
||||
}
|
||||
|
||||
adoptionChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map((p) => p.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'ASPA Coverage (%)',
|
||||
data: trendData.map((p) => p.coveragePercentage),
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#667eea',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { callback: (v) => v + '%' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRegionalData() {
|
||||
try {
|
||||
const response = await fetch('/api/aspa-adoption-stats/regional')
|
||||
if (!response.ok) throw new Error('Failed to load regional data')
|
||||
const data = await response.json()
|
||||
|
||||
const tbody = document.getElementById('regions-body')
|
||||
tbody.innerHTML = data.regions
|
||||
.map(
|
||||
(r) => `
|
||||
<tr>
|
||||
<td>${r.region}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div style="flex: 1;">
|
||||
<div class="coverage-bar">
|
||||
<div class="coverage-fill" style="width: ${r.coveragePercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span>${r.coveragePercentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${r.ASNsWithASPA} / ${r.totalASNs}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
} catch (error) {
|
||||
console.error('Error loading regional data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIXPData() {
|
||||
try {
|
||||
const response = await fetch('/api/aspa-adoption-stats/ixps?top=5')
|
||||
if (!response.ok) throw new Error('Failed to load IXP data')
|
||||
const data = await response.json()
|
||||
|
||||
const tbody = document.getElementById('ixps-body')
|
||||
tbody.innerHTML = data.ixps
|
||||
.map(
|
||||
(i) => `
|
||||
<tr>
|
||||
<td>${i.ixpName}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div style="flex: 1;">
|
||||
<div class="coverage-bar">
|
||||
<div class="coverage-fill" style="width: ${i.coveragePercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span>${i.coveragePercentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${i.participantsWithASPA} / ${i.participants}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
} catch (error) {
|
||||
console.error('Error loading IXP data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopAdopters(adopters) {
|
||||
const list = document.getElementById('top-adopters-list')
|
||||
list.innerHTML = adopters
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(a, i) => `
|
||||
<div style="padding: 10px 0; border-bottom: 1px solid #eee;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 600;">
|
||||
<span style="color: #667eea; margin-right: 8px;">#${i + 1}</span>
|
||||
${a.name}
|
||||
</span>
|
||||
<span style="color: #666; font-size: 12px;">${a.providers} provider${a.providers !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadData)
|
||||
setInterval(loadData, 5 * 60 * 1000)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -733,7 +733,7 @@ a:hover{text-decoration:underline}
|
||||
<div class="verdict-body">
|
||||
<div class="verdict-label">ASPA</div>
|
||||
<div class="verdict-value valid">DEPLOYED</div>
|
||||
<div class="verdict-sub">RFC 9582 — provider set complete</div>
|
||||
<div class="verdict-sub">ASPA draft, provider set complete</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -880,7 +880,7 @@ a:hover{text-decoration:underline}
|
||||
<div class="health-mark pass">✓</div>
|
||||
<div>
|
||||
<div class="health-text">ASPA record present</div>
|
||||
<div class="health-sub">RFC 9582 provider authorisation</div>
|
||||
<div class="health-sub">ASPA draft provider authorisation</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-item">
|
||||
|
||||
@ -554,7 +554,7 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,158,100,.12);border:1px solid rgba(255,158,100,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔐</div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange)">ASPA (RFC 9582)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">AS Provider Authorization from Cloudflare RPKI JSON feed. RFC-compliant upstream/downstream path verification with valley detection.</div><a href="https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-verification-14.html" target="_blank" style="font-size:.65rem;color:var(--blue)">IETF Draft-14</a></div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange)">ASPA (IETF draft, not yet an RFC)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">AS Provider Authorization from Cloudflare RPKI JSON feed. Draft-compliant upstream/downstream path verification with valley detection.</div><a href="https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/" target="_blank" style="font-size:.65rem;color:var(--blue)">IETF Draft</a></div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
|
||||
@ -581,7 +581,7 @@ a:hover{color:var(--purple)}
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
<div style="width:36px;height:36px;border-radius:0;background:rgba(180,83,9,.08);border:1px solid rgba(180,83,9,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔐</div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange);font-family:var(--body)">ASPA (RFC 9582)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">AS Provider Authorization from Cloudflare RPKI JSON feed. RFC-compliant upstream/downstream path verification with valley detection.</div><a href="https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-verification-14.html" target="_blank" style="font-size:.65rem;color:var(--blue);font-family:var(--mono)">IETF Draft-14</a></div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange);font-family:var(--body)">ASPA (IETF draft, not yet an RFC)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">AS Provider Authorization from Cloudflare RPKI JSON feed. Draft-compliant upstream/downstream path verification with valley detection.</div><a href="https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/" target="_blank" style="font-size:.65rem;color:var(--blue);font-family:var(--mono)">IETF Draft</a></div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex — The ASN News</title>
|
||||
<link rel="canonical" href="https://peercortex.org">
|
||||
<script defer src="https://analytics.fichtmueller.org/script.js" data-website-id="1cdd1e46-37f8-47c3-9b7f-a3992a46f5ed"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,800;0,900;1,400&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
@ -415,6 +417,18 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
.ac-name { font-family: var(--body); font-size: .82rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ac-meta { font-family: var(--mono); font-size: .6rem; color: var(--muted); white-space: nowrap; }
|
||||
.ac-source { font-family: var(--mono); font-size: .55rem; color: var(--dim); margin-left: auto; padding-left: .4rem; white-space: nowrap; }
|
||||
|
||||
.pdf-menu-item {
|
||||
display: block;
|
||||
padding: .45rem 1rem;
|
||||
font-family: var(--mono);
|
||||
font-size: .72rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pdf-menu-item:last-child { border-bottom: none; }
|
||||
.pdf-menu-item:hover { background: var(--purple); color: #fff !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -437,6 +451,7 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
<a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a>
|
||||
<a href="https://www.routeviews.org" target="_blank">Route Views</a>
|
||||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a>
|
||||
<a href="#" onclick="openAdoptionPanel();return false" style="color:var(--purple);border-bottom:2px solid var(--purple);padding-bottom:2px" title="ASPA & IPv6 Adoption Tracker">Adoption ✦</a>
|
||||
<span class="share-dropdown" id="shareDropdown">
|
||||
<a href="#" onclick="toggleShareMenu();return false" id="shareNavLink" title="Share" style="display:flex;align-items:center;gap:.35rem">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
@ -556,7 +571,7 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
</section>
|
||||
|
||||
<!-- ASPA Status -->
|
||||
<section class="card" id="aspaCard" title="ASPA (Autonomous System Provider Authorization) status — RFC 9582. Verifies whether this AS has published which providers are authorized to forward its routes, protecting against route leaks">
|
||||
<section class="card" id="aspaCard" title="ASPA (Autonomous System Provider Authorization) status. Still an IETF draft (draft-ietf-sidrops-aspa-profile), not yet an RFC. Verifies whether this AS has published which providers are authorized to forward its routes, protecting against route leaks">
|
||||
<div class="card-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2v-4M9 21H5a2 2 0 0 1-2-2v-4"/></svg>
|
||||
ASPA Status
|
||||
@ -813,7 +828,7 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
<div style="width:36px;height:36px;border-radius:0;background:rgba(180,83,9,.08);border:1px solid rgba(180,83,9,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔐</div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange);font-family:var(--body)">ASPA (RFC 9582)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">AS Provider Authorization from Cloudflare RPKI JSON feed. RFC-compliant upstream/downstream path verification with valley detection.</div><a href="https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-verification-14.html" target="_blank" style="font-size:.65rem;color:var(--blue);font-family:var(--mono)">IETF Draft-14</a></div>
|
||||
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange);font-family:var(--body)">ASPA (IETF draft, not yet an RFC)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">AS Provider Authorization from Cloudflare RPKI JSON feed. Draft-compliant upstream/downstream path verification with valley detection.</div><a href="https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/" target="_blank" style="font-size:.65rem;color:var(--blue);font-family:var(--mono)">IETF Draft</a></div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||||
@ -932,6 +947,7 @@ body.dark .card{border-top-color:#e8e4dc}
|
||||
<a href="https://stat.ripe.net" target="_blank">RIPE Stat</a>
|
||||
<a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a>
|
||||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a>
|
||||
<a href="#" onclick="openAdoptionPanel();return false" style="color:var(--purple);border-bottom:2px solid var(--purple);padding-bottom:2px" title="ASPA & IPv6 Adoption Tracker">Adoption ✦</a>
|
||||
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">GitHub</a>
|
||||
</nav>
|
||||
<div class="ed-footer-copy">v0.6.9 · Open Source · MIT License · Data powered by PeeringDB · RIPE Stat · RIPE Atlas · Route Views · bgp.he.net · bgproutes.io · RIPE DB · Cloudflare RPKI</div>
|
||||
@ -1459,6 +1475,17 @@ function renderDashboard(d) {
|
||||
if (n.looking_glass) ov += '<a class="ext-link" href="' + escAttr(n.looking_glass) + '" target="_blank">Looking Glass</a>';
|
||||
if (n.route_server) ov += '<a class="ext-link" href="' + escAttr(n.route_server) + '" target="_blank">Route Server</a>';
|
||||
ov += '<a class="ext-link" href="#" onclick="exportRawJson(event)" title="Download full lookup data as JSON">⬇ Raw JSON</a>';
|
||||
ov += '<span style="display:inline-block;vertical-align:middle;margin:0 .5rem">';
|
||||
ov += '<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted);letter-spacing:.06em;font-style:italic">Addon only for Izzy</span>';
|
||||
ov += '</span>';
|
||||
ov += '<span class="pdf-btn-wrap" style="position:relative;display:inline-block">';
|
||||
ov += '<a class="ext-link" href="#" onclick="togglePdfMenu(event)" style="color:var(--purple)">⬇ PDF ▾</a>';
|
||||
ov += '<div id="pdfMenu" style="display:none;position:absolute;top:1.6rem;left:0;background:var(--bg);border:1px solid var(--border);z-index:9000;white-space:nowrap;min-width:200px;box-shadow:0 4px 16px rgba(0,0,0,.2)">';
|
||||
ov += '<a href="/api/export/pdf?asn=' + n.asn + '&format=report" target="_blank" class="pdf-menu-item">Full Report (20+ pages)</a>';
|
||||
ov += '<a href="/api/export/pdf?asn=' + n.asn + '&format=executive" target="_blank" class="pdf-menu-item">Executive Summary (3-5 pages)</a>';
|
||||
ov += '<a href="/api/export/pdf?asn=' + n.asn + '&format=technical" target="_blank" class="pdf-menu-item">Technical Deep-Dive (10-15 pages)</a>';
|
||||
ov += '</div>';
|
||||
ov += '</span>';
|
||||
ov += '</div>';
|
||||
$('overviewContent').innerHTML = ov;
|
||||
|
||||
@ -2056,6 +2083,9 @@ async function doCompare() {
|
||||
}
|
||||
|
||||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Compared in ' + d.meta.duration_ms + 'ms</div>';
|
||||
h += '<div style="margin-top:.75rem;padding-top:.75rem;border-top:1px solid var(--border);text-align:right">';
|
||||
h += '<a href="/api/export/pdf/compare?asn1=' + currentAsn + '&asn2=' + raw2 + '" target="_blank" style="display:inline-block;padding:.45rem 1rem;background:var(--purple);color:#fff;font-family:var(--mono);font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;text-decoration:none" title="Download comparison as PDF">⬇ Download Comparison PDF</a>';
|
||||
h += '</div>';
|
||||
$('compareResults').innerHTML = h;
|
||||
} catch (e) {
|
||||
$('compareResults').innerHTML = '<div style="color:var(--red);font-size:.8rem">Compare failed: ' + escHtml(e.message) + '</div>';
|
||||
@ -2699,6 +2729,10 @@ function renderFullCompare(d) {
|
||||
|
||||
h += '</div>';
|
||||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.75rem">Compared in ' + d.meta.duration_ms + 'ms</div>';
|
||||
h += '<div style="margin-top:1rem;padding-top:.75rem;border-top:1px solid var(--border);display:flex;align-items:center;gap:.75rem">';
|
||||
h += '<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.06em;font-style:italic">Addon only for Izzy</span>';
|
||||
h += '<a href="/api/export/pdf/compare?asn1=' + d.asn1.asn + '&asn2=' + d.asn2.asn + '" target="_blank" style="display:inline-block;padding:.4rem 1rem;background:var(--purple);color:#fff;font-family:var(--mono);font-size:.68rem;letter-spacing:.06em;text-transform:uppercase;text-decoration:none">⬇ Download Comparison PDF</a>';
|
||||
h += '</div>';
|
||||
h += '</div>'; // end card
|
||||
|
||||
panel.innerHTML = h;
|
||||
@ -4317,6 +4351,18 @@ function renderSourceTiming(d) {
|
||||
}
|
||||
|
||||
// ── Raw JSON Export ────────────────────────────────────────────
|
||||
function togglePdfMenu(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var menu = document.getElementById('pdfMenu');
|
||||
if (!menu) return;
|
||||
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
document.addEventListener('click', function() {
|
||||
var menu = document.getElementById('pdfMenu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
});
|
||||
|
||||
function exportRawJson(e) {
|
||||
e.preventDefault();
|
||||
if (!window._lastLookupData) { alert('No lookup data available.'); return; }
|
||||
@ -4417,5 +4463,314 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeChangel
|
||||
<div id="changelogContent" style="padding:0 2rem 2rem;max-height:72vh;overflow-y:auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ADOPTION TRACKER OVERLAY -->
|
||||
<div id="adoptionOverlay" style="display:none;position:fixed;inset:0;background:rgba(28,25,23,.6);z-index:9999;align-items:flex-start;justify-content:center;padding:2rem 1rem;overflow-y:auto;backdrop-filter:blur(2px)">
|
||||
<div style="background:var(--bg);max-width:900px;width:100%;border-top:3px solid var(--text);position:relative">
|
||||
<div style="padding:1.5rem 2rem 1rem;border-bottom:1px solid var(--border);display:flex;align-items:baseline;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-family:var(--serif);font-size:1.6rem;font-weight:900;letter-spacing:-.02em;line-height:1">Adoption Tracker</div>
|
||||
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.1em;margin-top:.2rem">GLOBAL INTERNET ADOPTION METRICS</div>
|
||||
</div>
|
||||
<button onclick="closeAdoptionPanel()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-family:var(--mono);font-size:.65rem;letter-spacing:.08em;text-transform:uppercase;padding:.3rem .5rem">Close ✕</button>
|
||||
</div>
|
||||
<div style="display:flex;border-bottom:1px solid var(--border);padding:0 2rem">
|
||||
<button id="adoptTab-aspa" onclick="switchAdoptionTab('aspa')" style="background:none;border:none;border-bottom:2px solid var(--purple);color:var(--text);cursor:pointer;font-family:var(--mono);font-size:.65rem;letter-spacing:.08em;text-transform:uppercase;padding:.75rem 1.2rem .65rem;margin-bottom:-1px">ASPA Adoption</button>
|
||||
<button id="adoptTab-ipv6" onclick="switchAdoptionTab('ipv6')" style="background:none;border:none;border-bottom:2px solid transparent;color:var(--muted);cursor:pointer;font-family:var(--mono);font-size:.65rem;letter-spacing:.08em;text-transform:uppercase;padding:.75rem 1.2rem .65rem;margin-bottom:-1px">IPv6 per RIR</button>
|
||||
</div>
|
||||
<div style="padding:1.5rem 2rem 2rem;max-height:75vh;overflow-y:auto">
|
||||
<div id="adoptPanel-aspa">
|
||||
<div id="aspaAdoptContent"><p style="font-family:var(--mono);font-size:.75rem;color:var(--muted);text-align:center;padding:2rem">Loading ASPA data…</p></div>
|
||||
</div>
|
||||
<div id="adoptPanel-ipv6" style="display:none">
|
||||
<div id="ipv6AdoptContent"><p style="font-family:var(--mono);font-size:.75rem;color:var(--muted);text-align:center;padding:2rem">Loading IPv6 data…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var _adoptionLoaded = false;
|
||||
|
||||
window.openAdoptionPanel = function() {
|
||||
var overlay = document.getElementById('adoptionOverlay');
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (!_adoptionLoaded) { _adoptionLoaded = true; loadAllAdoptionData(); }
|
||||
};
|
||||
|
||||
window.closeAdoptionPanel = function() {
|
||||
document.getElementById('adoptionOverlay').style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
|
||||
document.getElementById('adoptionOverlay').addEventListener('click', function(e) {
|
||||
if (e.target === this) window.closeAdoptionPanel();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') window.closeAdoptionPanel();
|
||||
});
|
||||
|
||||
window.switchAdoptionTab = function(tab) {
|
||||
['aspa','ipv6'].forEach(function(t) {
|
||||
var btn = document.getElementById('adoptTab-' + t);
|
||||
var panel = document.getElementById('adoptPanel-' + t);
|
||||
var active = t === tab;
|
||||
btn.style.borderBottomColor = active ? 'var(--purple)' : 'transparent';
|
||||
btn.style.color = active ? 'var(--text)' : 'var(--muted)';
|
||||
panel.style.display = active ? 'block' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
function loadAllAdoptionData() {
|
||||
loadAspaAdoptionData();
|
||||
loadIpv6AdoptionData();
|
||||
}
|
||||
|
||||
/* ── ASPA ───────────────────────────────────────────────── */
|
||||
function loadAspaAdoptionData() {
|
||||
var el = document.getElementById('aspaAdoptContent');
|
||||
fetch('/api/aspa-adoption-stats?period=90d')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
el.innerHTML = renderAspaAdoption(d);
|
||||
setTimeout(function() { drawAspaChart(d); }, 60);
|
||||
})
|
||||
.catch(function() {
|
||||
el.innerHTML = '<p style="font-family:var(--mono);font-size:.75rem;color:#b83a1b;text-align:center;padding:2rem">Failed to load ASPA data</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderAspaAdoption(d) {
|
||||
var snaps = d.trend || [];
|
||||
var latestSnap = snaps.length ? snaps[snaps.length - 1] : {};
|
||||
var pct = (d.current || {}).coverage_pct_atlas || 0; var current = d.current || {};
|
||||
var atlasTotal = current.atlas_asns_total || 0;
|
||||
var aspaCnt = current.atlas_asns_with_aspa || 0;
|
||||
var totalAspa = current.aspa_objects || 0;
|
||||
var firstSnap = snaps.length > 1 ? snaps[0] : null; var currentDate = (d.current || {}).date || '';
|
||||
var delta = firstSnap ? Math.round((pct - (firstSnap.coverage_pct_atlas || 0)) * 100) / 100 : 0;
|
||||
var trend = delta >= 0 ? '+' + delta : '' + delta;
|
||||
var deltaColor = delta >= 0 ? '#2d6a4f' : '#b83a1b';
|
||||
|
||||
var rows = snaps.slice().reverse().slice(0, 15).map(function(s) {
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:.35rem .5rem">' + s.date + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem;color:var(--purple);font-weight:700">' + s.coverage_pct_atlas + '%</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (s.atlas_asns_with_aspa||0) + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (s.atlas_asns_total||0) + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (s.aspa_objects||0).toLocaleString() + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
|
||||
return '<div style="margin-bottom:1.5rem">'
|
||||
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem;margin-bottom:1.5rem">'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.3rem">Atlas Coverage</div>'
|
||||
+ '<div style="font-family:var(--serif);font-size:2rem;font-weight:900;letter-spacing:-.03em;color:var(--purple)">' + pct + '%</div>'
|
||||
+ '<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.25rem">' + aspaCnt + ' / ' + atlasTotal + ' ASNs</div>'
|
||||
+ '</div>'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.3rem">ASPA Objects</div>'
|
||||
+ '<div style="font-family:var(--serif);font-size:2rem;font-weight:900;letter-spacing:-.03em">' + totalAspa.toLocaleString() + '</div>'
|
||||
+ '<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.25rem">in RPKI</div>'
|
||||
+ '</div>'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.3rem">90d Change</div>'
|
||||
+ '<div style="font-family:var(--serif);font-size:2rem;font-weight:900;letter-spacing:-.03em;color:' + deltaColor + '">' + trend + '%</div>'
|
||||
+ '<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.25rem">vs. 90 days ago</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem;margin-bottom:1rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.75rem">Atlas Coverage Trend (' + (d.period||'90d') + ')</div>'
|
||||
+ '<canvas id="aspaChartCanvas" height="160" style="width:100%;display:block"></canvas>'
|
||||
+ '</div>'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.75rem">Recent Snapshots</div>'
|
||||
+ '<table style="width:100%;border-collapse:collapse;font-family:var(--mono);font-size:.72rem">'
|
||||
+ '<thead><tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<th style="text-align:left;padding:.35rem .5rem;color:var(--muted);font-weight:400">Date</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">Coverage %</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">ASNs w/ ASPA</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">Total Atlas</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">ASPA Objects</th>'
|
||||
+ '</tr></thead>'
|
||||
+ '<tbody>' + rows + '</tbody>'
|
||||
+ '</table>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function drawAspaChart(d) {
|
||||
var canvas = document.getElementById('aspaChartCanvas');
|
||||
if (!canvas) return;
|
||||
var snaps = d.snapshots || [];
|
||||
if (snaps.length < 2) { canvas.style.display = 'none'; return; }
|
||||
var pts = snaps.map(function(s) { return s.coverage_pct_atlas; });
|
||||
var W = canvas.offsetWidth || 800;
|
||||
canvas.width = W;
|
||||
var H = 160;
|
||||
canvas.height = H;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var minV = Math.max(0, Math.min.apply(null, pts) - 1);
|
||||
var maxV = Math.max.apply(null, pts) + 1;
|
||||
var pad = { t:10, b:24, l:40, r:10 };
|
||||
var w = W - pad.l - pad.r, h = H - pad.t - pad.b;
|
||||
var xf = function(i) { return pad.l + (i / (pts.length - 1)) * w; };
|
||||
var yf = function(v) { return pad.t + h - ((v - minV) / (maxV - minV)) * h; };
|
||||
var st = getComputedStyle(document.documentElement);
|
||||
var bc = st.getPropertyValue('--border').trim() || '#C9C3B6';
|
||||
var mc = st.getPropertyValue('--muted').trim() || '#8a8278';
|
||||
ctx.strokeStyle = bc; ctx.lineWidth = 1;
|
||||
for (var tick = 0; tick <= 4; tick++) {
|
||||
var yy = pad.t + (tick / 4) * h;
|
||||
ctx.beginPath(); ctx.moveTo(pad.l, yy); ctx.lineTo(pad.l + w, yy); ctx.stroke();
|
||||
ctx.fillStyle = mc; ctx.font = '10px monospace'; ctx.textAlign = 'right';
|
||||
ctx.fillText((maxV - (tick / 4) * (maxV - minV)).toFixed(1) + '%', pad.l - 4, yy + 4);
|
||||
}
|
||||
ctx.beginPath(); ctx.moveTo(xf(0), yf(pts[0]));
|
||||
for (var i2 = 1; i2 < pts.length; i2++) ctx.lineTo(xf(i2), yf(pts[i2]));
|
||||
ctx.lineTo(xf(pts.length - 1), H - pad.b); ctx.lineTo(xf(0), H - pad.b);
|
||||
ctx.closePath(); ctx.fillStyle = 'rgba(184,58,27,.12)'; ctx.fill();
|
||||
ctx.beginPath(); ctx.moveTo(xf(0), yf(pts[0]));
|
||||
for (var i3 = 1; i3 < pts.length; i3++) ctx.lineTo(xf(i3), yf(pts[i3]));
|
||||
ctx.strokeStyle = '#B83A1B'; ctx.lineWidth = 2; ctx.stroke();
|
||||
for (var i4 = 0; i4 < pts.length; i4++) {
|
||||
ctx.beginPath(); ctx.arc(xf(i4), yf(pts[i4]), 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#B83A1B'; ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = mc; ctx.font = '9px monospace'; ctx.textAlign = 'center';
|
||||
var step = Math.max(1, Math.ceil(pts.length / 6));
|
||||
for (var i5 = 0; i5 < snaps.length; i5++) {
|
||||
if (i5 % step === 0 || i5 === snaps.length - 1)
|
||||
ctx.fillText(snaps[i5].date.slice(5), xf(i5), H - pad.b + 14);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── IPv6 ───────────────────────────────────────────────── */
|
||||
var RIR_COLORS = { RIPE:'#4361ee', ARIN:'#3a0ca3', APNIC:'#7209b7', AFRINIC:'#f72585', LACNIC:'#4cc9f0' };
|
||||
|
||||
function loadIpv6AdoptionData() {
|
||||
var el = document.getElementById('ipv6AdoptContent');
|
||||
fetch('/api/ipv6-adoption-stats')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
el.innerHTML = renderIpv6Adoption(d);
|
||||
setTimeout(function() { drawIpv6Chart(d); }, 60);
|
||||
})
|
||||
.catch(function() {
|
||||
el.innerHTML = '<p style="font-family:var(--mono);font-size:.75rem;color:#b83a1b;text-align:center;padding:2rem">Failed to load IPv6 data</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderIpv6Adoption(d) {
|
||||
var rirs = d.rirs || [];
|
||||
var global = d.global || {};
|
||||
var fetchedAt = d.fetched_at ? new Date(d.fetched_at).toLocaleDateString() : 'N/A';
|
||||
|
||||
var statCards = rirs.map(function(r) {
|
||||
return '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.3rem">' + r.rir + '</div>'
|
||||
+ '<div style="font-family:var(--serif);font-size:1.8rem;font-weight:900;letter-spacing:-.03em;color:' + (RIR_COLORS[r.rir] || 'var(--text)') + '">' + r.ipv6_pct + '%</div>'
|
||||
+ '<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.25rem">' + (r.ipv6_blocks||0).toLocaleString() + ' / ' + (r.total_blocks||0).toLocaleString() + ' blocks</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
var tableRows = rirs.map(function(r) {
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:.35rem .5rem;font-weight:700">' + r.rir + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem;color:' + (RIR_COLORS[r.rir]||'var(--text)') + ';font-weight:700">' + r.ipv6_pct + '%</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (r.ipv6_blocks||0).toLocaleString() + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (r.ipv4_blocks||0).toLocaleString() + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (r.asn_records||0).toLocaleString() + '</td>'
|
||||
+ '<td style="text-align:right;padding:.35rem .5rem">' + (r.total_blocks||0).toLocaleString() + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
|
||||
tableRows += '<tr style="border-top:2px solid var(--border);font-weight:700">'
|
||||
+ '<td style="padding:.4rem .5rem">Global</td>'
|
||||
+ '<td style="text-align:right;padding:.4rem .5rem;color:var(--purple)">' + (global.ipv6_pct||0) + '%</td>'
|
||||
+ '<td style="text-align:right;padding:.4rem .5rem">' + (global.ipv6_blocks||0).toLocaleString() + '</td>'
|
||||
+ '<td style="text-align:right;padding:.4rem .5rem">' + (global.ipv4_blocks||0).toLocaleString() + '</td>'
|
||||
+ '<td style="text-align:right;padding:.4rem .5rem">' + (global.asn_records||0).toLocaleString() + '</td>'
|
||||
+ '<td style="text-align:right;padding:.4rem .5rem">' + (global.total_blocks||0).toLocaleString() + '</td>'
|
||||
+ '</tr>';
|
||||
|
||||
return '<div>'
|
||||
+ '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:1.5rem">'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.3rem">Global IPv6 Rate</div>'
|
||||
+ '<div style="font-family:var(--serif);font-size:2rem;font-weight:900;letter-spacing:-.03em;color:var(--purple)">' + (global.ipv6_pct||0) + '%</div>'
|
||||
+ '<div style="font-family:var(--mono);font-size:.65rem;color:var(--muted);margin-top:.25rem">of all address blocks</div>'
|
||||
+ '</div>'
|
||||
+ statCards
|
||||
+ '</div>'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem;margin-bottom:1rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.75rem">IPv6 Adoption by RIR</div>'
|
||||
+ '<canvas id="ipv6ChartCanvas" height="180" style="width:100%;display:block"></canvas>'
|
||||
+ '</div>'
|
||||
+ '<div style="border:1px solid var(--border);padding:1rem 1.25rem">'
|
||||
+ '<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:.75rem">Breakdown by RIR</div>'
|
||||
+ '<table style="width:100%;border-collapse:collapse;font-family:var(--mono);font-size:.72rem">'
|
||||
+ '<thead><tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<th style="text-align:left;padding:.35rem .5rem;color:var(--muted);font-weight:400">RIR</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">IPv6 %</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">IPv6 Blocks</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">IPv4 Blocks</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">ASN Records</th>'
|
||||
+ '<th style="text-align:right;padding:.35rem .5rem;color:var(--muted);font-weight:400">Total</th>'
|
||||
+ '</tr></thead>'
|
||||
+ '<tbody>' + tableRows + '</tbody>'
|
||||
+ '</table>'
|
||||
+ '<p style="font-family:var(--mono);font-size:.6rem;color:var(--muted);margin-top:.75rem">Source: RIR delegation files (RIPE, ARIN, APNIC, AFRINIC, LACNIC) · Updated: ' + fetchedAt + '</p>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function drawIpv6Chart(d) {
|
||||
var canvas = document.getElementById('ipv6ChartCanvas');
|
||||
if (!canvas) return;
|
||||
var rirs = d.rirs || [];
|
||||
if (!rirs.length) return;
|
||||
var W = canvas.offsetWidth || 800;
|
||||
canvas.width = W;
|
||||
var H = 180;
|
||||
canvas.height = H;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var pad = { t:10, b:32, l:42, r:10 };
|
||||
var w = W - pad.l - pad.r, h = H - pad.t - pad.b;
|
||||
var maxPct = Math.ceil(Math.max.apply(null, rirs.map(function(r) { return r.ipv6_pct; })) / 5) * 5 + 5;
|
||||
var barW = Math.floor(w / rirs.length * 0.55);
|
||||
var gap = w / rirs.length;
|
||||
var st2 = getComputedStyle(document.documentElement);
|
||||
var bc2 = st2.getPropertyValue('--border').trim() || '#C9C3B6';
|
||||
var mc2 = st2.getPropertyValue('--muted').trim() || '#8a8278';
|
||||
ctx.strokeStyle = bc2; ctx.lineWidth = 1;
|
||||
for (var tick = 0; tick <= 4; tick++) {
|
||||
var yy = pad.t + (tick / 4) * h;
|
||||
ctx.beginPath(); ctx.moveTo(pad.l, yy); ctx.lineTo(pad.l + w, yy); ctx.stroke();
|
||||
ctx.fillStyle = mc2; ctx.font = '10px monospace'; ctx.textAlign = 'right';
|
||||
ctx.fillText((maxPct - (tick / 4) * maxPct).toFixed(0) + '%', pad.l - 4, yy + 4);
|
||||
}
|
||||
rirs.forEach(function(r, i) {
|
||||
var barH = (r.ipv6_pct / maxPct) * h;
|
||||
var bx = pad.l + gap * i + (gap - barW) / 2;
|
||||
var by = pad.t + h - barH;
|
||||
ctx.fillStyle = RIR_COLORS[r.rir] || '#666';
|
||||
ctx.fillRect(bx, by, barW, barH);
|
||||
if (barH > 18) {
|
||||
ctx.fillStyle = '#fff'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center';
|
||||
ctx.fillText(r.ipv6_pct + '%', bx + barW / 2, by + barH / 2 + 4);
|
||||
}
|
||||
ctx.fillStyle = mc2; ctx.font = '10px monospace'; ctx.textAlign = 'center';
|
||||
ctx.fillText(r.rir, bx + barW / 2, H - pad.b + 14);
|
||||
});
|
||||
}
|
||||
|
||||
})(); // end IIFE
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4421
public/index.html.bak-20260429_211603
Normal file
4421
public/index.html.bak-20260429_211603
Normal file
File diff suppressed because it is too large
Load Diff
1129
public/public/design-draft-editorial.html
Normal file
1129
public/public/design-draft-editorial.html
Normal file
File diff suppressed because it is too large
Load Diff
2898
public/public/index-classic.html
Normal file
2898
public/public/index-classic.html
Normal file
File diff suppressed because it is too large
Load Diff
3332
public/public/index-editorial.html
Normal file
3332
public/public/index-editorial.html
Normal file
File diff suppressed because it is too large
Load Diff
4126
public/public/index.html
Normal file
4126
public/public/index.html
Normal file
File diff suppressed because it is too large
Load Diff
2357
public/public/index.html.bak
Normal file
2357
public/public/index.html.bak
Normal file
File diff suppressed because it is too large
Load Diff
488
public/public/lia.html
Normal file
488
public/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>
|
||||
420
public/public/shell.html
Normal file
420
public/public/shell.html
Normal file
@ -0,0 +1,420 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PeerCortex Shell — Feedback Admin</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{height:100%;background:#0a0a0a;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;line-height:1.7;overflow:hidden}
|
||||
|
||||
#shell{display:flex;flex-direction:column;height:100vh;max-width:900px;margin:0 auto;padding:0 1rem}
|
||||
|
||||
/* Title bar */
|
||||
#titlebar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-bottom:1px solid rgba(0,255,65,.12);flex-shrink:0;margin-bottom:.25rem}
|
||||
.tb-dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex-shrink:0}
|
||||
#tb-title{flex:1;text-align:center;color:rgba(0,255,65,.45);font-size:.68rem;letter-spacing:.1em}
|
||||
|
||||
/* Output */
|
||||
#output{flex:1;overflow-y:auto;padding:.5rem 0;word-break:break-word}
|
||||
#output div{padding:.05rem 0}
|
||||
|
||||
/* Input bar */
|
||||
#inputbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 0;border-top:1px solid rgba(0,255,65,.12);flex-shrink:0}
|
||||
#prompt{color:rgba(0,255,65,.55);white-space:nowrap;flex-shrink:0}
|
||||
#cmd{flex:1;background:transparent;border:none;outline:none;color:#00ff41;font-family:'IBM Plex Mono','Courier New',monospace;font-size:.82rem;caret-color:#00ff41}
|
||||
|
||||
/* Scrollbar */
|
||||
#output::-webkit-scrollbar{width:4px}
|
||||
#output::-webkit-scrollbar-track{background:transparent}
|
||||
#output::-webkit-scrollbar-thumb{background:rgba(0,255,65,.2);border-radius:2px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
<div id="titlebar">
|
||||
<span class="tb-dot" style="background:#ff5f56"></span>
|
||||
<span class="tb-dot" style="background:#ffbd2e"></span>
|
||||
<span class="tb-dot" style="background:#27c93f"></span>
|
||||
<span id="tb-title">shell.peercortex.org — admin terminal</span>
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
<div id="inputbar">
|
||||
<span id="prompt">shell:~$</span>
|
||||
<input id="cmd" type="text" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var token = null;
|
||||
var allEntries = [];
|
||||
|
||||
var G = 'color:#27c93f';
|
||||
var DIM = 'color:rgba(0,255,65,.35)';
|
||||
var MUT = 'color:rgba(0,255,65,.45)';
|
||||
var Y = 'color:#ffbd2e';
|
||||
var R = 'color:rgba(255,100,100,.8)';
|
||||
var W = 'color:#fff';
|
||||
|
||||
// Safe DOM output — no innerHTML on user data
|
||||
function print(template, fallbackColor) {
|
||||
var out = document.getElementById('output');
|
||||
var line = document.createElement('div');
|
||||
if (fallbackColor) line.style.color = fallbackColor;
|
||||
var parts = template.split(/(<span[^>]*>[^<]*<\/span>)/g);
|
||||
parts.forEach(function(part) {
|
||||
var m = part.match(/^<span([^>]*)>(.*?)<\/span>$/);
|
||||
if (m) {
|
||||
var sp = document.createElement('span');
|
||||
var sm = m[1].match(/style="([^"]*)"/);
|
||||
if (sm) sp.style.cssText = sm[1];
|
||||
sp.textContent = m[2];
|
||||
line.appendChild(sp);
|
||||
} else if (part) {
|
||||
line.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
out.appendChild(line);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function printText(text, color) {
|
||||
var out = document.getElementById('output');
|
||||
var line = document.createElement('div');
|
||||
line.textContent = text;
|
||||
if (color) line.style.color = color;
|
||||
out.appendChild(line);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function blank() { print(''); }
|
||||
|
||||
function clear() { document.getElementById('output').textContent = ''; }
|
||||
|
||||
function setPrompt(p) { document.getElementById('prompt').textContent = p; }
|
||||
|
||||
// Boot sequence
|
||||
function boot() {
|
||||
clear();
|
||||
token = null;
|
||||
allEntries = [];
|
||||
setPrompt('shell:~$');
|
||||
var lines = [
|
||||
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
|
||||
'',
|
||||
' <span style="' + G + ';font-weight:600">PeerCortex — Feedback Administration Shell</span>',
|
||||
' <span style="' + MUT + '">Unauthorized access prohibited. All sessions logged.</span>',
|
||||
'',
|
||||
'<span style="' + DIM + '">══════════════════════════════════════════════════════════</span>',
|
||||
'',
|
||||
'Type <span style="' + G + '">login</span> to authenticate.',
|
||||
''
|
||||
];
|
||||
var d = 0;
|
||||
lines.forEach(function(l){ setTimeout(function(){ print(l); }, d); d += 40; });
|
||||
}
|
||||
|
||||
// Command dispatch
|
||||
function dispatch(raw) {
|
||||
var val = raw.trim();
|
||||
var low = val.toLowerCase();
|
||||
var args = val.split(/\s+/);
|
||||
var cmd = args[0].toLowerCase();
|
||||
|
||||
// Echo input safely
|
||||
(function(){
|
||||
var out = document.getElementById('output');
|
||||
var d = document.createElement('div');
|
||||
var sp = document.createElement('span');
|
||||
sp.style.color = 'rgba(0,255,65,.35)';
|
||||
sp.textContent = document.getElementById('prompt').textContent;
|
||||
d.appendChild(sp);
|
||||
d.appendChild(document.createTextNode(' ' + val));
|
||||
out.appendChild(d);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
})();
|
||||
|
||||
if (!token) {
|
||||
// Unauthenticated mode
|
||||
if (cmd === 'login') {
|
||||
doLogin();
|
||||
} else if (cmd === 'help') {
|
||||
showHelp(false);
|
||||
} else if (cmd === 'clear') {
|
||||
clear();
|
||||
} else if (val === '') {
|
||||
// noop
|
||||
} else {
|
||||
printText('Permission denied. Type login first.', 'rgba(255,100,100,.75)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated mode
|
||||
if (cmd === 'list') {
|
||||
var cat = args[1] || null;
|
||||
doList(cat);
|
||||
} else if (cmd === 'show') {
|
||||
var idx = parseInt(args[1], 10);
|
||||
doShow(idx);
|
||||
} else if (cmd === 'stats') {
|
||||
doStats();
|
||||
} else if (cmd === 'export') {
|
||||
doExport();
|
||||
} else if (cmd === 'refresh') {
|
||||
doFetch(function(){ print('<span style="' + G + '">✓ Data refreshed. ' + allEntries.length + ' entries loaded.</span>'); });
|
||||
} else if (cmd === 'clear') {
|
||||
clear();
|
||||
} else if (cmd === 'logout') {
|
||||
token = null; allEntries = [];
|
||||
setPrompt('shell:~$');
|
||||
blank();
|
||||
printText('Session terminated.', MUT.slice(6));
|
||||
blank();
|
||||
printText('Type login to re-authenticate.');
|
||||
} else if (cmd === 'help') {
|
||||
showHelp(true);
|
||||
} else if (val === '') {
|
||||
// noop
|
||||
} else {
|
||||
printText('shell: ' + val + ': command not found', 'rgba(255,100,100,.75)');
|
||||
}
|
||||
}
|
||||
|
||||
// Login flow
|
||||
var loginState = 0;
|
||||
function doLogin() {
|
||||
loginState = 1;
|
||||
setPrompt('token:');
|
||||
blank();
|
||||
printText('Enter FEEDBACK_TOKEN:');
|
||||
document.getElementById('cmd').type = 'password';
|
||||
}
|
||||
|
||||
function handleLogin(val) {
|
||||
document.getElementById('cmd').type = 'text';
|
||||
loginState = 0;
|
||||
setPrompt('shell:~$');
|
||||
blank();
|
||||
// Verify token
|
||||
fetch('/api/feedback?token=' + encodeURIComponent(val))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.ok) {
|
||||
token = val;
|
||||
allEntries = d.entries || [];
|
||||
setPrompt('root@peercortex:~#');
|
||||
print('<span style="' + G + '">✓ Authenticated. ' + allEntries.length + ' feedback entries loaded.</span>');
|
||||
blank();
|
||||
showHelp(true);
|
||||
} else {
|
||||
printText('✗ Authentication failed. Wrong token.', 'rgba(255,100,100,.8)');
|
||||
blank();
|
||||
}
|
||||
}).catch(function(){
|
||||
printText('Network error during authentication.', 'rgba(255,100,100,.8)');
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch fresh data from server
|
||||
function doFetch(cb) {
|
||||
fetch('/api/feedback?token=' + encodeURIComponent(token))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.ok) { allEntries = d.entries || []; if (cb) cb(); }
|
||||
else { printText('Fetch error: ' + (d.error || 'unknown'), 'rgba(255,100,100,.8)'); }
|
||||
}).catch(function(){ printText('Network error.', 'rgba(255,100,100,.8)'); });
|
||||
}
|
||||
|
||||
// list [category]
|
||||
function doList(filterCat) {
|
||||
var data = allEntries;
|
||||
if (filterCat) {
|
||||
var lf = filterCat.toLowerCase();
|
||||
data = data.filter(function(e){ return e.category && e.category.toLowerCase().indexOf(lf) >= 0; });
|
||||
}
|
||||
blank();
|
||||
if (data.length === 0) {
|
||||
printText('No entries' + (filterCat ? ' matching "' + filterCat + '"' : '') + '.', MUT.slice(6));
|
||||
blank();
|
||||
return;
|
||||
}
|
||||
print('<span style="' + DIM + '">── # DATE CATEGORY NAME ASN ──</span>');
|
||||
data.forEach(function(e, i) {
|
||||
var realIdx = allEntries.indexOf(e);
|
||||
var date = e.timestamp ? e.timestamp.slice(0,10) : '?';
|
||||
var cat = (e.category || 'General').slice(0,18).padEnd(18);
|
||||
var name = (e.name || 'Anonymous').slice(0,16).padEnd(16);
|
||||
var asn = e.asn ? ('AS' + e.asn) : '—';
|
||||
var num = String(realIdx + 1).padStart(3);
|
||||
(function(entry, n){
|
||||
var out = document.getElementById('output');
|
||||
var line = document.createElement('div');
|
||||
var snr = document.createElement('span');
|
||||
snr.style.color = 'rgba(0,255,65,.4)';
|
||||
snr.textContent = num + ' ';
|
||||
var sdat = document.createElement('span');
|
||||
sdat.style.color = '#ffbd2e';
|
||||
sdat.textContent = date + ' ';
|
||||
var scat = document.createElement('span');
|
||||
scat.style.color = '#00ff41';
|
||||
scat.textContent = cat + ' ';
|
||||
var snm = document.createElement('span');
|
||||
snm.style.color = 'rgba(0,255,65,.7)';
|
||||
snm.textContent = name + ' ';
|
||||
var sasn = document.createElement('span');
|
||||
sasn.style.color = 'rgba(0,255,65,.45)';
|
||||
sasn.textContent = asn;
|
||||
line.appendChild(snr); line.appendChild(sdat); line.appendChild(scat);
|
||||
line.appendChild(snm); line.appendChild(sasn);
|
||||
out.appendChild(line);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
})(e, realIdx);
|
||||
});
|
||||
print('<span style="' + DIM + '">──────────────────────────────────────────────────────────────</span>');
|
||||
var out2 = document.getElementById('output');
|
||||
var tot = document.createElement('div');
|
||||
tot.style.color = 'rgba(0,255,65,.45)';
|
||||
tot.textContent = data.length + ' entr' + (data.length === 1 ? 'y' : 'ies') + (filterCat ? ' (filtered)' : '') + ' — type show <n> for full message';
|
||||
out2.appendChild(tot);
|
||||
out2.scrollTop = out2.scrollHeight;
|
||||
blank();
|
||||
}
|
||||
|
||||
// show <n>
|
||||
function doShow(n) {
|
||||
if (isNaN(n) || n < 1 || n > allEntries.length) {
|
||||
printText('Usage: show <number> (1–' + allEntries.length + ')', 'rgba(255,189,46,.8)');
|
||||
return;
|
||||
}
|
||||
var e = allEntries[n - 1];
|
||||
blank();
|
||||
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
|
||||
(function(){
|
||||
var fields = [
|
||||
['ID', e.id || '?'],
|
||||
['Timestamp', e.timestamp || '?'],
|
||||
['Category', e.category || 'General'],
|
||||
['Name', e.name || 'Anonymous'],
|
||||
['ASN', e.asn ? 'AS' + e.asn : '—'],
|
||||
['IP', e.ip || '—'],
|
||||
];
|
||||
fields.forEach(function(f){
|
||||
var out = document.getElementById('output');
|
||||
var d = document.createElement('div');
|
||||
var lbl = document.createElement('span');
|
||||
lbl.style.color = 'rgba(0,255,65,.45)';
|
||||
lbl.textContent = f[0].padEnd(12) + ' ';
|
||||
var val = document.createElement('span');
|
||||
val.style.color = '#00ff41';
|
||||
val.textContent = f[1];
|
||||
d.appendChild(lbl); d.appendChild(val);
|
||||
out.appendChild(d);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
});
|
||||
})();
|
||||
blank();
|
||||
printText('Message:', 'rgba(0,255,65,.45)');
|
||||
printText(e.message || '(empty)', '#fff');
|
||||
blank();
|
||||
print('<span style="' + DIM + '">────────────────────────────────────────────────────────────</span>');
|
||||
blank();
|
||||
}
|
||||
|
||||
// stats
|
||||
function doStats() {
|
||||
if (allEntries.length === 0) { printText('No feedback entries yet.', MUT.slice(6)); return; }
|
||||
var cats = {};
|
||||
var asns = {};
|
||||
allEntries.forEach(function(e){
|
||||
var c = e.category || 'General';
|
||||
cats[c] = (cats[c] || 0) + 1;
|
||||
if (e.asn) { asns[e.asn] = (asns[e.asn] || 0) + 1; }
|
||||
});
|
||||
blank();
|
||||
print('<span style="' + G + ';font-weight:600">─── Feedback Statistics ───────────────────</span>');
|
||||
var out = document.getElementById('output');
|
||||
var tot = document.createElement('div');
|
||||
tot.style.color = '#ffbd2e';
|
||||
tot.textContent = 'Total entries: ' + allEntries.length;
|
||||
out.appendChild(tot);
|
||||
blank();
|
||||
printText('By category:', 'rgba(0,255,65,.45)');
|
||||
Object.keys(cats).sort(function(a,b){ return cats[b]-cats[a]; }).forEach(function(c){
|
||||
var d2 = document.createElement('div');
|
||||
var bar = '█'.repeat(Math.round(cats[c] / allEntries.length * 20));
|
||||
var sp1 = document.createElement('span');
|
||||
sp1.style.color = 'rgba(0,255,65,.4)';
|
||||
sp1.textContent = ' ' + c.padEnd(20);
|
||||
var sp2 = document.createElement('span');
|
||||
sp2.style.color = '#27c93f';
|
||||
sp2.textContent = bar + ' ' + cats[c];
|
||||
d2.appendChild(sp1); d2.appendChild(sp2);
|
||||
out.appendChild(d2);
|
||||
});
|
||||
var topAsn = Object.keys(asns);
|
||||
if (topAsn.length > 0) {
|
||||
blank();
|
||||
printText('Top ASNs reported:', 'rgba(0,255,65,.45)');
|
||||
topAsn.sort(function(a,b){ return asns[b]-asns[a]; }).slice(0,5).forEach(function(a){
|
||||
var d3 = document.createElement('div');
|
||||
d3.style.color = 'rgba(0,255,65,.7)';
|
||||
d3.textContent = ' AS' + a + ' — ' + asns[a] + ' report' + (asns[a]===1?'':'s');
|
||||
out.appendChild(d3);
|
||||
});
|
||||
}
|
||||
out.scrollTop = out.scrollHeight;
|
||||
blank();
|
||||
}
|
||||
|
||||
// export — trigger JSON file download
|
||||
function doExport() {
|
||||
var blob = new Blob([JSON.stringify(allEntries, null, 2)], {type:'application/json'});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'peercortex-feedback-' + new Date().toISOString().slice(0,10) + '.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
print('<span style="' + G + '">✓ Export downloaded: ' + allEntries.length + ' entries.</span>');
|
||||
blank();
|
||||
}
|
||||
|
||||
// help
|
||||
function showHelp(authed) {
|
||||
blank();
|
||||
print('<span style="' + G + ';font-weight:600">Available commands:</span>');
|
||||
if (!authed) {
|
||||
print(' <span style="' + G + '">login</span> — authenticate with FEEDBACK_TOKEN');
|
||||
print(' <span style="' + G + '">clear</span> — clear screen');
|
||||
} else {
|
||||
print(' <span style="' + G + '">list</span> — show all feedback entries');
|
||||
print(' <span style="' + G + '">list [category]</span> — filter by category (bug, feature, design, general)');
|
||||
print(' <span style="' + G + '">show [n]</span> — show full message for entry #n');
|
||||
print(' <span style="' + G + '">stats</span> — category + ASN statistics');
|
||||
print(' <span style="' + G + '">export</span> — download all entries as JSON');
|
||||
print(' <span style="' + G + '">refresh</span> — reload entries from server');
|
||||
print(' <span style="' + G + '">clear</span> — clear screen');
|
||||
print(' <span style="' + G + '">logout</span> — end session');
|
||||
}
|
||||
blank();
|
||||
}
|
||||
|
||||
// Input handler
|
||||
document.getElementById('cmd').addEventListener('keydown', function(e){
|
||||
if (e.key !== 'Enter') return;
|
||||
var val = this.value;
|
||||
this.value = '';
|
||||
if (loginState === 1) {
|
||||
handleLogin(val.trim());
|
||||
} else {
|
||||
dispatch(val);
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
11
scripts/refresh-peeringdb.sh
Executable file
11
scripts/refresh-peeringdb.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Refresh PeeringDB SQLite mirror daily
|
||||
|
||||
LOG=/var/log/peercortex/peeringdb-refresh.log
|
||||
mkdir -p /var/log/peercortex
|
||||
exec >> $LOG 2>&1
|
||||
|
||||
echo "[$(date +%Y-%m-%dT%H:%M:%S)] Starting PeeringDB sync..."
|
||||
cd /opt/peeringdb-data
|
||||
peeringdb sync -q
|
||||
echo "[$(date +%Y-%m-%dT%H:%M:%S)] Sync complete. File: $(du -sh peeringdb.sqlite3 | cut -f1)"
|
||||
29
scripts/tunnel-cleanup.sh
Executable file
29
scripts/tunnel-cleanup.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Auto-heal Cloudflare tunnel: clean stale connectors from foreign IPs
|
||||
TUNNEL=3262c64b-51d5-479f-ad26-a9925b705bd3
|
||||
LOG=/var/log/peercortex/tunnel-cleanup.log
|
||||
|
||||
mkdir -p /var/log/peercortex
|
||||
|
||||
# Restart cloudflared if not running
|
||||
if ! systemctl is-active --quiet cloudflared; then
|
||||
echo "[$(date +%Y-%m-%dT%H:%M:%S)] cloudflared not running — starting" >> "$LOG"
|
||||
systemctl start cloudflared
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
MY_IP=$(curl -s --max-time 5 https://ifconfig.me 2>/dev/null)
|
||||
[ -z "$MY_IP" ] && exit 0
|
||||
|
||||
# Connector rows start with a UUID (36 chars: 8-4-4-4-12)
|
||||
# Column 5 in connector rows = ORIGIN IP
|
||||
STALE=$(cloudflared tunnel info "$TUNNEL" 2>/dev/null | \
|
||||
grep -E '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | \
|
||||
awk -v myip="$MY_IP" '$5 != myip {print $5}')
|
||||
|
||||
if [ -n "$STALE" ]; then
|
||||
echo "[$(date +%Y-%m-%dT%H:%M:%S)] Stale connectors from $STALE — cleaning" >> "$LOG"
|
||||
cloudflared tunnel cleanup "$TUNNEL" >> "$LOG" 2>&1
|
||||
systemctl restart cloudflared
|
||||
echo "[$(date +%Y-%m-%dT%H:%M:%S)] Done" >> "$LOG"
|
||||
fi
|
||||
5660
server.js.backup-v0.7.0
Normal file
5660
server.js.backup-v0.7.0
Normal file
File diff suppressed because it is too large
Load Diff
855
server/__smoke__/baseline/baseline.json
Normal file
855
server/__smoke__/baseline/baseline.json
Normal file
@ -0,0 +1,855 @@
|
||||
{
|
||||
"root": {
|
||||
"status": 500,
|
||||
"body": {
|
||||
"__non_json__": true,
|
||||
"status": 500,
|
||||
"bodyLength": 20
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"q": "cloudflare",
|
||||
"results": [
|
||||
{
|
||||
"asn": "13335",
|
||||
"label": "CLOUDFLARENET - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "14789",
|
||||
"label": "CLOUDFLARENET - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "132892",
|
||||
"label": "CLOUDFLARE - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "133877",
|
||||
"label": "CLOUDFLARE - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "202623",
|
||||
"label": "CLOUDFLARENET-CORE Cloudflare Inc",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "203898",
|
||||
"label": "CLOUDFLARENET-UK Cloudflare Inc",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "209242",
|
||||
"label": "CLOUDFLARESPECTRUM Cloudflare London, LLC",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "394536",
|
||||
"label": "CLOUDFLARENET-SFO - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "395747",
|
||||
"label": "CLOUDFLARENET-SFO05 - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "400095",
|
||||
"label": "CLOUDFLARENET - Cloudflare, Inc.",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "402542",
|
||||
"label": "CLOUDFLARENET - Cloudflare,",
|
||||
"description": "",
|
||||
"source": "RIPE Stat"
|
||||
},
|
||||
{
|
||||
"asn": "4436",
|
||||
"label": "GTT Communications (AS4436)",
|
||||
"description": "NSP",
|
||||
"source": "PeeringDB"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"visitors": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"visitors": 1
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"status": "ok",
|
||||
"service": "PeerCortex",
|
||||
"version": "0.6.9",
|
||||
"bgproutes_configured": false,
|
||||
"caches": {
|
||||
"aspa_map": {},
|
||||
"pdb_net": {
|
||||
"entries": 0,
|
||||
"hit_rate_pct": 0
|
||||
},
|
||||
"pdb_netixlan": {
|
||||
"entries": 0
|
||||
},
|
||||
"pdb_netfac": {
|
||||
"entries": 0
|
||||
},
|
||||
"ripe_stat": {
|
||||
"entries": 0
|
||||
},
|
||||
"response_cache": {
|
||||
"entries": 0
|
||||
}
|
||||
},
|
||||
"local_db": null,
|
||||
"aspa_adoption": {}
|
||||
}
|
||||
},
|
||||
"aspa-verify": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {
|
||||
"query": "AS13335",
|
||||
"paths_analyzed": 0,
|
||||
"total_paths_seen": 0
|
||||
},
|
||||
"asn": 13335,
|
||||
"readiness_score": {
|
||||
"total": 0,
|
||||
"breakdown": {
|
||||
"roa_coverage": {
|
||||
"score": 0,
|
||||
"max": 25,
|
||||
"value": 0
|
||||
},
|
||||
"aspa_object": {
|
||||
"score": 0,
|
||||
"max": 25,
|
||||
"value": false
|
||||
},
|
||||
"provider_completeness": {
|
||||
"score": 0,
|
||||
"max": 25,
|
||||
"value": 0
|
||||
},
|
||||
"path_validation": {
|
||||
"score": 0,
|
||||
"max": 25,
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"aspa_object_exists": false,
|
||||
"aspa_feed_healthy": true,
|
||||
"detected_providers": [],
|
||||
"provider_audit": {
|
||||
"declared_count": 0,
|
||||
"detected_count": 0,
|
||||
"completeness_pct": 0,
|
||||
"missing_from_aspa": [],
|
||||
"extra_in_aspa": []
|
||||
},
|
||||
"path_verification": {
|
||||
"total": 0,
|
||||
"valid": 0,
|
||||
"invalid": 0,
|
||||
"unknown": 0,
|
||||
"as_set_flagged": 0,
|
||||
"valley_detected": 0,
|
||||
"valid_pct": 0,
|
||||
"not_invalid_pct": 0,
|
||||
"results": []
|
||||
},
|
||||
"rpki_coverage": 0
|
||||
}
|
||||
},
|
||||
"aspa-legacy": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {
|
||||
"query": "AS13335"
|
||||
},
|
||||
"asn": 13335,
|
||||
"detected_providers": [],
|
||||
"provider_count": 0,
|
||||
"aspa_object_exists": false,
|
||||
"aspa_feed_healthy": true,
|
||||
"aspa_declared_providers": [],
|
||||
"aspa_declared_count": 0,
|
||||
"recommended_aspa": "aut-num: AS13335\n# Recommended ASPA object:\n# customer: AS13335\n# provider-set: \n# AFI: ipv4, ipv6\n#\n# Detected providers from BGP path analysis:\n",
|
||||
"path_analysis": {
|
||||
"total_paths_seen": 0,
|
||||
"sample_paths": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"bgp": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {
|
||||
"source": "local_bgp_db"
|
||||
},
|
||||
"bgp_status": {
|
||||
"asn": "13335",
|
||||
"announced": false,
|
||||
"announced_count": 0,
|
||||
"message": "No prefixes found for this ASN in local BGP table",
|
||||
"source": "local_bgp"
|
||||
},
|
||||
"threat_intel": null
|
||||
}
|
||||
},
|
||||
"validate": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {
|
||||
"query": "AS13335",
|
||||
"total_prefixes": 0,
|
||||
"prefixes_sampled": 0
|
||||
},
|
||||
"asn": 13335,
|
||||
"name": "Unknown",
|
||||
"validations": {
|
||||
"irr": {
|
||||
"status": "info",
|
||||
"message": "timed out"
|
||||
},
|
||||
"rpki_completeness": {
|
||||
"status": "fail",
|
||||
"coverage_pct": 0,
|
||||
"total_checked": 0,
|
||||
"db_unavailable_count": 0,
|
||||
"with_roa": 0,
|
||||
"over_specific": [],
|
||||
"details": []
|
||||
},
|
||||
"abuse_contact": {
|
||||
"status": "pass",
|
||||
"contacts": [
|
||||
"abuse@cloudflare.com"
|
||||
],
|
||||
"has_valid_email": true
|
||||
},
|
||||
"blocklist": {
|
||||
"status": "pass",
|
||||
"checked": 0,
|
||||
"unavailable": 0,
|
||||
"listed_prefixes": []
|
||||
},
|
||||
"manrs": {
|
||||
"status": "info",
|
||||
"participant": "unknown",
|
||||
"message": "MANRS data not yet loaded",
|
||||
"note": "https://www.manrs.org/netops/participants/"
|
||||
},
|
||||
"rdns": {
|
||||
"status": "fail",
|
||||
"coverage_pct": 0,
|
||||
"checked": 0,
|
||||
"unavailable": 0,
|
||||
"results": [],
|
||||
"failed_prefixes": []
|
||||
},
|
||||
"communities": {
|
||||
"status": "pass",
|
||||
"total_updates": 0,
|
||||
"unique_communities": 0,
|
||||
"top_communities": [],
|
||||
"well_known_detected": []
|
||||
},
|
||||
"geolocation": {
|
||||
"status": "warning",
|
||||
"geo_countries": [],
|
||||
"sample_prefix": null,
|
||||
"located_resources": 0,
|
||||
"pdb_facility_countries": [],
|
||||
"country_mismatches": []
|
||||
},
|
||||
"rpsl": {
|
||||
"status": "info",
|
||||
"message": "timed out"
|
||||
},
|
||||
"ix_route_server": {
|
||||
"status": "warning",
|
||||
"total_ix_connections": 0,
|
||||
"rs_peer_count": 0,
|
||||
"rs_peer_pct": 0,
|
||||
"note": "Only 0 IX connection(s) and no route server usage"
|
||||
},
|
||||
"resource_cert": {
|
||||
"status": "fail",
|
||||
"has_roas": false,
|
||||
"checked": 0
|
||||
},
|
||||
"bogon": {
|
||||
"status": "pass",
|
||||
"bogon_prefixes": [],
|
||||
"bogon_asns_in_paths": [],
|
||||
"total_prefixes_checked": 0,
|
||||
"prefixes_source_unavailable": false,
|
||||
"neighbours_source_unavailable": true
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"counts": {
|
||||
"upstreams": 0,
|
||||
"downstreams": 0,
|
||||
"peers": 0,
|
||||
"uncertain": 0
|
||||
},
|
||||
"upstreams": [],
|
||||
"downstreams": [],
|
||||
"top_peers": [],
|
||||
"source": "RIPE Stat asn-neighbours",
|
||||
"note": "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lookup": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {
|
||||
"service": "PeerCortex",
|
||||
"version": "0.6.9",
|
||||
"query": "AS13335",
|
||||
"sources": [
|
||||
"PeeringDB",
|
||||
"RIPE Stat",
|
||||
"bgp.he.net",
|
||||
"Cloudflare RPKI",
|
||||
"RIPE RPKI Validator",
|
||||
"Route Views"
|
||||
],
|
||||
"rpki_prefixes_checked": 0,
|
||||
"total_prefixes": 0,
|
||||
"degraded_sources": [
|
||||
"RIPE Stat Neighbours",
|
||||
"RIPE Stat Overview",
|
||||
"RIPE Stat Visibility",
|
||||
"RIPE Stat PrefixSize"
|
||||
]
|
||||
},
|
||||
"network": {
|
||||
"asn": 13335,
|
||||
"name": "Unknown",
|
||||
"aka": "",
|
||||
"org_name": "",
|
||||
"website": "",
|
||||
"type": "",
|
||||
"policy": "",
|
||||
"traffic": "",
|
||||
"ratio": "",
|
||||
"scope": "",
|
||||
"notes": "",
|
||||
"peeringdb_id": null,
|
||||
"rir": "ARIN",
|
||||
"country": "",
|
||||
"city": "",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"looking_glass": "",
|
||||
"route_server": "",
|
||||
"info_prefixes4": 0,
|
||||
"info_prefixes6": 0,
|
||||
"status": "",
|
||||
"peeringdb_created": "",
|
||||
"peeringdb_updated": ""
|
||||
},
|
||||
"prefixes": {
|
||||
"total": 0,
|
||||
"ipv4": 0,
|
||||
"ipv6": 0,
|
||||
"list": [],
|
||||
"cross_check": {
|
||||
"ripe_stat": 0,
|
||||
"bgp_he_net": null,
|
||||
"agreement": null,
|
||||
"note": "bgp.he.net data unavailable"
|
||||
}
|
||||
},
|
||||
"rpki": {
|
||||
"coverage_percent": 0,
|
||||
"valid": 0,
|
||||
"invalid": 0,
|
||||
"not_found": 0,
|
||||
"unavailable": 0,
|
||||
"checked": 0,
|
||||
"details": [],
|
||||
"cross_check": {
|
||||
"cloudflare_valid": 0,
|
||||
"ripe_valid": 0,
|
||||
"agreement_pct": null,
|
||||
"disagreements": [],
|
||||
"sample_size": 0,
|
||||
"comparable_size": 0
|
||||
}
|
||||
},
|
||||
"neighbours": {
|
||||
"total": 0,
|
||||
"upstream_count": 0,
|
||||
"downstream_count": 0,
|
||||
"peer_count": 0,
|
||||
"upstreams": [],
|
||||
"downstreams": [],
|
||||
"peers": [],
|
||||
"cross_check": {
|
||||
"ripe_stat_total": 0,
|
||||
"bgp_he_net_total": null
|
||||
}
|
||||
},
|
||||
"ix_presence": {
|
||||
"total_connections": 0,
|
||||
"unique_ixps": 0,
|
||||
"connections": []
|
||||
},
|
||||
"ix_locations": [],
|
||||
"facilities": {
|
||||
"total": 0,
|
||||
"list": []
|
||||
},
|
||||
"routing": {
|
||||
"ipv4_prefixes": 0,
|
||||
"ipv6_prefixes": 0,
|
||||
"ipv4_visibility_avg": 0,
|
||||
"ipv6_visibility_avg": 0,
|
||||
"total_ris_peers_v4": 0,
|
||||
"total_ris_peers_v6": 0,
|
||||
"prefix_sizes_v4": [],
|
||||
"prefix_sizes_v6": []
|
||||
},
|
||||
"resilience_score": null,
|
||||
"route_leak": {
|
||||
"detected": false,
|
||||
"patterns": [],
|
||||
"tier1_upstream_count": 0,
|
||||
"tier1_downstream_count": 0,
|
||||
"_provenance": {
|
||||
"source": "RIPE Stat asn-neighbours",
|
||||
"validation": "heuristic",
|
||||
"confidence": "medium",
|
||||
"note": "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships."
|
||||
}
|
||||
},
|
||||
"bgp_he_net": null,
|
||||
"atlas": {
|
||||
"connected": 0,
|
||||
"disconnected": 0,
|
||||
"anchors": 0,
|
||||
"probes": []
|
||||
},
|
||||
"data_quality": {
|
||||
"sources_queried": [
|
||||
"PeeringDB",
|
||||
"RIPE Stat",
|
||||
"bgp.he.net",
|
||||
"Cloudflare RPKI",
|
||||
"RIPE RPKI Validator"
|
||||
],
|
||||
"cross_checks": {
|
||||
"rpki": {
|
||||
"sources": 1,
|
||||
"agreement_pct": null,
|
||||
"sample_size": 0,
|
||||
"comparable_size": 0,
|
||||
"disagreements": []
|
||||
},
|
||||
"prefixes": {
|
||||
"sources": 1,
|
||||
"agreement_pct": null,
|
||||
"ripe_stat": 0,
|
||||
"bgp_he_net": null,
|
||||
"note": "bgp.he.net data unavailable"
|
||||
},
|
||||
"neighbours": {
|
||||
"sources": 1,
|
||||
"agreement_pct": null,
|
||||
"ripe_stat_total": 0,
|
||||
"bgp_he_net_total": null
|
||||
}
|
||||
},
|
||||
"overall_confidence": "unknown",
|
||||
"overall_agreement_pct": null
|
||||
},
|
||||
"contacts": [],
|
||||
"registration": {
|
||||
"created": "2010-07-14",
|
||||
"last_modified": "2017-02-17",
|
||||
"rir": "ARIN",
|
||||
"handle": "AS13335",
|
||||
"rdap_source": "whois.arin.net"
|
||||
},
|
||||
"_provenance": {
|
||||
"prefixes": {
|
||||
"source": "RIPE Stat announced-prefixes",
|
||||
"validation": "cross-validated",
|
||||
"confidence": "high",
|
||||
"note": "Cross-checked with bgp.he.net prefix count daily"
|
||||
},
|
||||
"neighbours": {
|
||||
"source": "RIPE Stat asn-neighbours",
|
||||
"validation": "cross-validated",
|
||||
"confidence": "high",
|
||||
"note": "Cross-checked with bgp.he.net peer count daily"
|
||||
},
|
||||
"rpki": {
|
||||
"source": "Cloudflare RPKI + RIPE Validator",
|
||||
"validation": "cross-validated",
|
||||
"confidence": "high",
|
||||
"note": "Two independent RPKI sources compared"
|
||||
},
|
||||
"ix_presence": {
|
||||
"source": "PeeringDB netixlan (local mirror)",
|
||||
"validation": "cross-validated",
|
||||
"confidence": "high",
|
||||
"note": "PeeringDB mirror refreshed daily"
|
||||
},
|
||||
"facilities": {
|
||||
"source": "PeeringDB netfac (local mirror)",
|
||||
"validation": "single-source",
|
||||
"confidence": "medium"
|
||||
},
|
||||
"bgp_he_net": {
|
||||
"source": "bgp.he.net HTML scrape",
|
||||
"validation": "single-source",
|
||||
"confidence": "medium",
|
||||
"note": "HTML scrape, no official API — may have parsing drift"
|
||||
},
|
||||
"atlas": {
|
||||
"source": "RIPE Atlas API",
|
||||
"validation": "single-source",
|
||||
"confidence": "medium",
|
||||
"note": "Probe availability varies by region"
|
||||
},
|
||||
"resilience_score": {
|
||||
"source": "Computed from RIPE Stat + PeeringDB",
|
||||
"validation": "computed",
|
||||
"confidence": "high",
|
||||
"note": "All inputs cross-validated daily"
|
||||
},
|
||||
"route_leak": {
|
||||
"source": "RIPE Stat asn-neighbours heuristic",
|
||||
"validation": "heuristic",
|
||||
"confidence": "medium",
|
||||
"note": "Pattern-based, not real-time — false positives possible"
|
||||
},
|
||||
"registration": {
|
||||
"source": "RDAP (RIR registry)",
|
||||
"validation": "single-source",
|
||||
"confidence": "high"
|
||||
},
|
||||
"contacts": {
|
||||
"source": "PeeringDB POC API",
|
||||
"validation": "single-source",
|
||||
"confidence": "medium",
|
||||
"note": "Subject to PeeringDB rate limiting"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"asn": 13335,
|
||||
"counts": {
|
||||
"upstreams": 0,
|
||||
"downstreams": 0,
|
||||
"peers_total": 0,
|
||||
"uncertain": 0
|
||||
},
|
||||
"upstreams": [],
|
||||
"downstreams": [],
|
||||
"peers": [],
|
||||
"methodology": "RIPE Stat asn-neighbours API. left=upstream providers (carry your traffic), right=downstream customers (you carry their traffic), uncertain=lateral peers. Sorted by power score (number of prefixes seen via this relationship).",
|
||||
"source_url": "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS13335"
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {},
|
||||
"asn1": {
|
||||
"asn": 13335,
|
||||
"name": "Unknown",
|
||||
"ix_count": 0,
|
||||
"fac_count": 0,
|
||||
"upstream_count": 0,
|
||||
"rpki_coverage": 0
|
||||
},
|
||||
"asn2": {
|
||||
"asn": 15169,
|
||||
"name": "Unknown",
|
||||
"ix_count": 0,
|
||||
"fac_count": 0,
|
||||
"upstream_count": 0,
|
||||
"rpki_coverage": 0
|
||||
},
|
||||
"common_ixps": [],
|
||||
"only_asn1_ixps": [],
|
||||
"only_asn2_ixps": [],
|
||||
"common_facilities": [],
|
||||
"common_upstreams": [],
|
||||
"rpki_comparison": {
|
||||
"asn1_coverage": 0,
|
||||
"asn2_coverage": 0,
|
||||
"asn1_checked": 0,
|
||||
"asn2_checked": 0,
|
||||
"better": "equal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quick-ix": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"asn": 13335
|
||||
}
|
||||
},
|
||||
"peers-find": {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"error": "Missing ix parameter (IX name)"
|
||||
}
|
||||
},
|
||||
"prefix-detail": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"meta": {},
|
||||
"prefix": "1.1.1.0/24",
|
||||
"rpki": {
|
||||
"status": "unknown",
|
||||
"validating_roas": 0
|
||||
},
|
||||
"irr_status": "found",
|
||||
"visibility": {
|
||||
"ris_peers_seeing": 0,
|
||||
"source": "ripe_stat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ix-detail": {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"error": "Missing ix_id parameter"
|
||||
}
|
||||
},
|
||||
"topology": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"nodes": [
|
||||
{
|
||||
"asn": 13335,
|
||||
"name": "",
|
||||
"type": "target",
|
||||
"depth": 0
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"target_asn": 13335,
|
||||
"depth": 2,
|
||||
"meta": {
|
||||
"query": "AS13335",
|
||||
"depth": 2,
|
||||
"node_count": 1,
|
||||
"edge_count": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"whois": {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"error": "Missing resource parameter (ASN, prefix, or domain)"
|
||||
}
|
||||
},
|
||||
"enrich": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"asn": "13335",
|
||||
"description": null,
|
||||
"wiki_url": null
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"status": 500,
|
||||
"body": {
|
||||
"__non_json__": true,
|
||||
"status": 500,
|
||||
"bodyLength": 23
|
||||
}
|
||||
},
|
||||
"webhooks-list": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"webhooks": [],
|
||||
"total": 0
|
||||
}
|
||||
},
|
||||
"hijacks-list": {
|
||||
"status": 200,
|
||||
"body": {}
|
||||
},
|
||||
"hijack-alerts": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"asn": "",
|
||||
"monitoring": false,
|
||||
"prefix_count": 0
|
||||
}
|
||||
},
|
||||
"aspa-adoption-page": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"__non_json__": true,
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"aspa-adoption-stats": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"period": "30d",
|
||||
"current": {
|
||||
"date": "2026-07-17"
|
||||
},
|
||||
"trend": [
|
||||
{
|
||||
"date": "2026-06-17"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-18"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-19"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-20"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-21"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-22"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-23"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-24"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-25"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-26"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-27"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-28"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-29"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-30"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-01"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-02"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-03"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-04"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-05"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-06"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-08"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-09"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-11"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-15"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-16"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-17"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total_snapshots": 80,
|
||||
"denominator": "atlas_visible_asns",
|
||||
"source": "rpki_cloudflare_feed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipv6-adoption-stats": {
|
||||
"status": 200,
|
||||
"body": {}
|
||||
},
|
||||
"atlas-coverage": {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"pages_fetched": 30
|
||||
}
|
||||
},
|
||||
"submarine-cables": {
|
||||
"status": 502,
|
||||
"body": {
|
||||
"error": "API server unavailable",
|
||||
"detail": "connect ECONNREFUSED 127.0.0.1:39102"
|
||||
}
|
||||
},
|
||||
"communities": {
|
||||
"status": 502,
|
||||
"body": {
|
||||
"error": "API server unavailable",
|
||||
"detail": "connect ECONNREFUSED 127.0.0.1:39102"
|
||||
}
|
||||
}
|
||||
}
|
||||
301
server/__smoke__/compare-responses.js
Normal file
301
server/__smoke__/compare-responses.js
Normal file
@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
// Regression gate for the server.js module-extraction refactor (see
|
||||
// /Users/renefichtmueller/.claude/plans/linked-riding-newell.md). Not shipped
|
||||
// to prod -- local dev tool only.
|
||||
//
|
||||
// Usage:
|
||||
// node server/__smoke__/compare-responses.js capture # save baseline (run once, before Phase A)
|
||||
// node server/__smoke__/compare-responses.js diff # run after every extraction step
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn, execFileSync } = require("child_process");
|
||||
|
||||
// server.js writes real snapshots into these git-tracked files at startup
|
||||
// (ASPA adoption history) and at runtime (hijack alerts, webhook subs) --
|
||||
// snapshot + restore around every local boot so smoke-testing never leaves
|
||||
// lasting mutations on data that's supposed to hold genuine production
|
||||
// history, not local test noise.
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..");
|
||||
const MUTABLE_TRACKED_FILES = ["aspa-adoption-history.json", "hijack-alerts.json", "webhook-subs.json"];
|
||||
|
||||
function restoreTrackedFiles() {
|
||||
try {
|
||||
execFileSync("git", ["checkout", "--", ...MUTABLE_TRACKED_FILES], { cwd: REPO_ROOT, stdio: "ignore" });
|
||||
} catch {
|
||||
// fine if a file has no uncommitted changes (git checkout on a clean path is a no-op anyway)
|
||||
}
|
||||
}
|
||||
|
||||
const PORT = 3199;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const BASELINE_DIR = path.join(__dirname, "baseline");
|
||||
const ASN = 13335; // Cloudflare -- stable, public, always has real data
|
||||
const ASN2 = 15169; // Google -- for compare/relationships
|
||||
|
||||
// One representative, read-only (GET) request per route from the exploration
|
||||
// map. POST/DELETE mutating routes (feedback POST, webhooks POST/DELETE,
|
||||
// hijacks resolve) are intentionally excluded -- not idempotent-safe to
|
||||
// re-run repeatedly across every extraction step.
|
||||
const ENDPOINTS = [
|
||||
["root", "/"],
|
||||
["search", `/api/search?q=cloudflare`],
|
||||
["visitors", "/api/visitors"],
|
||||
["health", "/api/health"],
|
||||
["aspa-verify", `/api/aspa/verify?asn=${ASN}`],
|
||||
["aspa-legacy", `/api/aspa?asn=${ASN}`],
|
||||
["bgp", `/api/bgp?asn=${ASN}`],
|
||||
["validate", `/api/validate?asn=${ASN}`],
|
||||
["lookup", `/api/lookup?asn=${ASN}`],
|
||||
["relationships", `/api/relationships?asn=${ASN}`],
|
||||
["compare", `/api/compare?asn1=${ASN}&asn2=${ASN2}`],
|
||||
["quick-ix", `/api/quick-ix?asn=${ASN}`],
|
||||
["peers-find", `/api/peers/find?asn=${ASN}`],
|
||||
["prefix-detail", "/api/prefix/detail?prefix=1.1.1.0/24"],
|
||||
["ix-detail", "/api/ix/detail?asn=${ASN}"],
|
||||
["topology", `/api/topology?asn=${ASN}`],
|
||||
["whois", `/api/whois?asn=${ASN}`],
|
||||
["enrich", `/api/enrich?asn=${ASN}`],
|
||||
["changelog", "/changelog"],
|
||||
["webhooks-list", "/api/webhooks"],
|
||||
["hijacks-list", "/api/hijacks"],
|
||||
["hijack-alerts", "/api/hijack-alerts"],
|
||||
["aspa-adoption-page", "/aspa-adoption"],
|
||||
["aspa-adoption-stats", "/api/aspa-adoption-stats?period=30d"],
|
||||
["ipv6-adoption-stats", "/api/ipv6-adoption-stats"],
|
||||
["atlas-coverage", "/api/atlas/coverage"],
|
||||
// proxy stubs -- expected to fail identically (peercortex-api not running
|
||||
// locally either); still worth diffing so the *error shape* stays stable
|
||||
["submarine-cables", "/api/submarine-cables"],
|
||||
["communities", "/api/communities?asn=" + ASN],
|
||||
];
|
||||
|
||||
// Fields that legitimately change between runs -- excluded from the diff.
|
||||
// Two sources of real (non-bug) drift observed empirically by diffing an
|
||||
// unmodified server.js against itself: (1) process-local counters (uptime,
|
||||
// memory), and (2) live third-party data that changes minute-to-minute
|
||||
// (RIPE Atlas probe counts/IDs, Cloudflare RPKI feed size, health_score
|
||||
// insofar as it's derived from live external checks like MANRS that can
|
||||
// time out differently run to run).
|
||||
const NONDETERMINISTIC_KEY_PATTERN =
|
||||
/^(generated_at|timestamp|checked_at|fetched_at|query_time|cache_age|age_seconds|age_minutes|uptime_seconds|memory_mb|response_time_ms|duration_ms|last_updated|health_score|delta_last|history_samples|total_probes|total_connected|unique_asns_with_probes|atlas_asns_total|atlas_asns_with_aspa|coverage_pct_atlas|asns_with_probes|roa_count|aspa_objects)$/i;
|
||||
|
||||
// Whole sub-trees excluded by dotted path (relative to the endpoint's body),
|
||||
// found empirically by diffing an unmodified server.js against itself twice.
|
||||
// Each of these depends on a live third-party call that can legitimately
|
||||
// time out or return fresh-but-different data run to run (RIPE Stat routing
|
||||
// status, RIS prefix history, PeeringDB unauthenticated rate limits, RIPE
|
||||
// Atlas probe churn, source-fetch timing diagnostics) -- none of it reflects
|
||||
// server.js's own logic, so a diff here is noise, not a regression signal.
|
||||
const VOLATILE_PATHS = new Set([
|
||||
"lookup.source_timing",
|
||||
"validate.validations.visibility",
|
||||
// redundant summary of validations.* (already diffed in detail) that also
|
||||
// embeds the visibility check's flaky pass/info status
|
||||
"validate.score_breakdown",
|
||||
"prefix-detail.origins",
|
||||
"prefix-detail.first_seen",
|
||||
"aspa-adoption-stats.forecast",
|
||||
"quick-ix.ix_connections",
|
||||
"quick-ix.name",
|
||||
"atlas-coverage.by_country",
|
||||
// live Cloudflare RPKI feed object count ticks between runs; date-rollover
|
||||
// (midnight) shifts the adoption tracker's day-over-day delta too
|
||||
"health.caches.aspa_map.entries",
|
||||
"health.aspa_adoption.total_objects",
|
||||
"aspa-adoption-stats.current.delta_from_previous",
|
||||
// RIR delegation files (RIPE/ARIN/APNIC/AFRINIC/LACNIC) update near-real-time
|
||||
"ipv6-adoption-stats.global_ipv6_pct",
|
||||
"ipv6-adoption-stats.total_ipv4_records",
|
||||
"ipv6-adoption-stats.total_ipv6_records",
|
||||
"ipv6-adoption-stats.by_rir",
|
||||
// local hijack-alerts.json keeps accumulating real detector events between
|
||||
// runs (not a live third-party feed, but still grows independent of code
|
||||
// changes); the dashboard's byte length shifts with the daily rollover too
|
||||
"hijacks-list.total",
|
||||
"hijacks-list.events",
|
||||
"hijack-alerts.alerts",
|
||||
"aspa-adoption-page.bodyLength",
|
||||
]);
|
||||
|
||||
function stripNondeterministic(obj, pathPrefix) {
|
||||
if (Array.isArray(obj)) return obj.map((v) => stripNondeterministic(v, pathPrefix));
|
||||
if (obj && typeof obj === "object") {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (NONDETERMINISTIC_KEY_PATTERN.test(k)) continue;
|
||||
const fullPath = pathPrefix ? `${pathPrefix}.${k}` : k;
|
||||
if (VOLATILE_PATHS.has(fullPath)) continue;
|
||||
out[k] = stripNondeterministic(v, fullPath);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function fetchOne(name, pathAndQuery) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(BASE + pathAndQuery, { timeout: 20000 }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
parsed = { __non_json__: true, status: res.statusCode, bodyLength: body.length };
|
||||
}
|
||||
resolve({ status: res.statusCode, body: stripNondeterministic(parsed, name) });
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ status: 0, body: { __timeout__: true } });
|
||||
});
|
||||
req.on("error", (err) => resolve({ status: 0, body: { __error__: err.message } }));
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServer(maxWaitMs = 100000) {
|
||||
// Startup does real, sequential network fetches (Cloudflare RPKI/ROA feed
|
||||
// ~972k entries, full paginated RIPE Atlas probe list, IPv6 RIR delegation
|
||||
// stats) before calling server.listen() -- observed ~60s locally.
|
||||
const start = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tryOnce = () => {
|
||||
const req = http.get(`${BASE}/api/health`, { timeout: 2000 }, (res) => {
|
||||
res.resume();
|
||||
resolve();
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time"));
|
||||
setTimeout(tryOnce, 500);
|
||||
});
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time"));
|
||||
setTimeout(tryOnce, 500);
|
||||
});
|
||||
};
|
||||
tryOnce();
|
||||
});
|
||||
}
|
||||
|
||||
async function runAgainstLiveServer() {
|
||||
const results = {};
|
||||
for (const [name, pathAndQuery] of ENDPOINTS) {
|
||||
results[name] = await fetchOne(name, pathAndQuery);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function leafDiffs(a, b, pathPrefix, out) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) return;
|
||||
const bothPlainObjects =
|
||||
a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b);
|
||||
if (bothPlainObjects) {
|
||||
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
leafDiffs(a[k], b[k], pathPrefix + "." + k, out);
|
||||
}
|
||||
} else {
|
||||
out.push(`${pathPrefix}: ${JSON.stringify(a)} -> ${JSON.stringify(b)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function diffResults(baseline, current) {
|
||||
const diffs = [];
|
||||
for (const [name] of ENDPOINTS) {
|
||||
const b = JSON.stringify(baseline[name]);
|
||||
const c = JSON.stringify(current[name]);
|
||||
if (b !== c) {
|
||||
const leaves = [];
|
||||
leafDiffs(baseline[name], current[name], name, leaves);
|
||||
diffs.push({ name, leaves });
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2];
|
||||
if (mode !== "capture" && mode !== "diff") {
|
||||
console.error("Usage: node compare-responses.js <capture|diff>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
restoreTrackedFiles();
|
||||
|
||||
const serverEntry = path.join(__dirname, "..", "..", "server.js");
|
||||
const child = spawn(process.execPath, [serverEntry], {
|
||||
cwd: path.join(__dirname, "..", ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(PORT),
|
||||
// Node 22's stricter header validation throws on Authorization:
|
||||
// undefined, which server.js's fetchPdbOrgCountries() produces when
|
||||
// PEERINGDB_API_KEY is unset (line ~6689). This is a real bug (tracked
|
||||
// separately, fixed during the pdb-org-countries.js extraction in
|
||||
// Phase B) but it blocks the server from even booting for local
|
||||
// testing -- work around it here at the test-harness level only, so
|
||||
// the baseline reflects real behavior rather than a crash.
|
||||
PEERINGDB_API_KEY: process.env.PEERINGDB_API_KEY || "smoke-test-placeholder",
|
||||
// Force the proxy-to-peercortex-api path to genuinely fail closed
|
||||
// instead of cross-talking with whatever unrelated dev server happens
|
||||
// to be listening on the real default port (3102) on this machine --
|
||||
// observed proxying straight into an unrelated Next.js app's 404 page.
|
||||
API_SERVER_PORT: "39102",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderrBuf = "";
|
||||
child.stderr.on("data", (c) => (stderrBuf += c));
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {}
|
||||
};
|
||||
process.on("exit", cleanup);
|
||||
|
||||
try {
|
||||
await waitForServer();
|
||||
console.log(`Server up on port ${PORT}, running ${ENDPOINTS.length} requests...`);
|
||||
const results = await runAgainstLiveServer();
|
||||
|
||||
if (mode === "capture") {
|
||||
fs.mkdirSync(BASELINE_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(BASELINE_DIR, "baseline.json"), JSON.stringify(results, null, 2));
|
||||
console.log(`Baseline captured: ${ENDPOINTS.length} endpoints -> ${BASELINE_DIR}/baseline.json`);
|
||||
} else {
|
||||
const baselinePath = path.join(BASELINE_DIR, "baseline.json");
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
console.error("No baseline found -- run `capture` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
||||
const diffs = diffResults(baseline, results);
|
||||
if (diffs.length === 0) {
|
||||
console.log(`PASS -- all ${ENDPOINTS.length} endpoints match baseline.`);
|
||||
} else {
|
||||
console.log(`FAIL -- ${diffs.length}/${ENDPOINTS.length} endpoint(s) differ from baseline:`);
|
||||
for (const d of diffs) {
|
||||
console.log(`\n=== ${d.name} (${d.leaves.length} leaf change(s)) ===`);
|
||||
for (const line of d.leaves.slice(0, 20)) console.log(" " + line);
|
||||
if (d.leaves.length > 20) console.log(` ... and ${d.leaves.length - 20} more`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Smoke test failed to run:", err.message);
|
||||
if (stderrBuf) console.error("--- server stderr ---\n" + stderrBuf.slice(0, 3000));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
cleanup();
|
||||
restoreTrackedFiles();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
291
server/aspa-adoption/tracker.js
Normal file
291
server/aspa-adoption/tracker.js
Normal file
@ -0,0 +1,291 @@
|
||||
const fs = require("fs");
|
||||
const { DATA_DIR } = require("../hijack-monitoring/store");
|
||||
const { atlasState } = require("../atlas-probes");
|
||||
|
||||
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
|
||||
|
||||
// Load persisted ASPA adoption history from disk.
|
||||
// Returns array of snapshot objects (up to 365 entries).
|
||||
function loadAspaAdoptionHistory() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch(_) { return []; }
|
||||
}
|
||||
|
||||
// Save ASPA adoption history to disk (keep last 365 entries).
|
||||
function saveAspaAdoptionHistory(history) {
|
||||
try {
|
||||
const trimmed = history.slice(-365);
|
||||
fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
|
||||
} catch(e) {
|
||||
console.error('[ASPA-ADOPT] Save failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory adoption history (persisted on each new snapshot). Mutated
|
||||
// in place (push / index-assign), never reassigned -- safe to export as a
|
||||
// live array reference, same pattern as roaStore/pdbSourceCache.
|
||||
let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
|
||||
|
||||
// Take a new adoption snapshot and persist it. Called whenever the RPKI feed
|
||||
// is refreshed.
|
||||
// @param {number} aspaCount — total ASPA objects in rpkiAspaMap
|
||||
// @param {number} roaCount — total ROAs in roaStore
|
||||
// @param {Map} rpkiAspaMap — customer_asid -> Set<provider_asn>, owned by
|
||||
// server/services/rpki.js; passed in explicitly (rather than required here)
|
||||
// to avoid a circular require between this module and rpki.js.
|
||||
function recordAspaAdoptionSnapshot(aspaCount, roaCount, rpkiAspaMap) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
|
||||
|
||||
// Compute how many Atlas-visible ASNs have ASPA
|
||||
let atlasTotal = 0;
|
||||
let atlasWithAspa = 0;
|
||||
if (atlasState.cache?.asns_with_probes) {
|
||||
atlasTotal = atlasState.cache.asns_with_probes.length;
|
||||
// rpkiAspaMap is keyed by integer ASN
|
||||
atlasWithAspa = atlasState.cache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
|
||||
}
|
||||
|
||||
const coveragePct = atlasTotal > 0
|
||||
? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
|
||||
: 0;
|
||||
|
||||
// Compute delta from previous snapshot
|
||||
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||
const delta = prev ? aspaCount - prev.aspa_objects : 0;
|
||||
|
||||
const snapshot = {
|
||||
date,
|
||||
ts: now.getTime(),
|
||||
aspa_objects: aspaCount,
|
||||
roa_count: roaCount,
|
||||
atlas_asns_total: atlasTotal,
|
||||
atlas_asns_with_aspa: atlasWithAspa,
|
||||
coverage_pct_atlas: coveragePct,
|
||||
aspa_delta: delta,
|
||||
method: 'rpki_cloudflare_feed',
|
||||
};
|
||||
|
||||
// Only store one snapshot per date (overwrite if same day)
|
||||
const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
|
||||
if (existingIdx >= 0) {
|
||||
aspaAdoptionDailyHistory[existingIdx] = snapshot;
|
||||
} else {
|
||||
aspaAdoptionDailyHistory.push(snapshot);
|
||||
}
|
||||
saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
|
||||
console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
|
||||
function getAdoptionTrend(period) {
|
||||
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
|
||||
}
|
||||
|
||||
// Build the ASPA adoption dashboard HTML.
|
||||
function buildAspaAdoptionDashboard() {
|
||||
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||
const trend30 = getAdoptionTrend('30d');
|
||||
const trend7 = getAdoptionTrend('7d');
|
||||
|
||||
// Forecast: simple linear regression over last 30 days
|
||||
function linearForecast(data, daysAhead) {
|
||||
if (data.length < 2) return null;
|
||||
const n = data.length;
|
||||
const xs = data.map((_, i) => i);
|
||||
const ys = data.map(d => d.coverage_pct_atlas);
|
||||
const xMean = xs.reduce((a, b) => a + b, 0) / n;
|
||||
const yMean = ys.reduce((a, b) => a + b, 0) / n;
|
||||
const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0);
|
||||
const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0);
|
||||
if (den === 0) return yMean;
|
||||
const slope = num / den;
|
||||
return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead)));
|
||||
}
|
||||
|
||||
const forecast90d = linearForecast(trend30, 90);
|
||||
const change7d = trend7.length >= 2
|
||||
? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
|
||||
: 0;
|
||||
const trendLabels = trend30.map(d => d.date);
|
||||
const trendValues = trend30.map(d => d.coverage_pct_atlas);
|
||||
const asnValues = trend30.map(d => d.aspa_objects);
|
||||
|
||||
const coverageNow = latest?.coverage_pct_atlas ?? 0;
|
||||
const aspaNow = latest?.aspa_objects ?? 0;
|
||||
const atlasTotal = latest?.atlas_asns_total ?? 0;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>ASPA Adoption Tracker · PeerCortex</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;background:#f5f3ff;color:#1e1b4b;min-height:100vh}
|
||||
nav{background:#4f46e5;color:#fff;padding:14px 24px;display:flex;align-items:center;gap:12px}
|
||||
nav .logo{font-size:16px;font-weight:700;letter-spacing:-0.5px}
|
||||
nav .logo span{opacity:0.7;font-weight:400}
|
||||
nav a{color:rgba(255,255,255,0.8);text-decoration:none;font-size:13px;margin-left:16px}
|
||||
nav a:hover{color:#fff}
|
||||
.container{max-width:1100px;margin:0 auto;padding:24px 20px}
|
||||
h1{font-size:22px;font-weight:700;color:#3730a3;margin-bottom:4px}
|
||||
.subtitle{font-size:13px;color:#6b7280;margin-bottom:24px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}
|
||||
.card{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
|
||||
.card .label{font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
|
||||
.card .value{font-size:28px;font-weight:700;color:#4f46e5;line-height:1}
|
||||
.card .unit{font-size:11px;color:#9ca3af;margin-top:4px}
|
||||
.card.green .value{color:#16a34a}
|
||||
.card.amber .value{color:#d97706}
|
||||
.chart-wrap{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:20px;margin-bottom:24px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
|
||||
.chart-wrap h2{font-size:14px;font-weight:600;color:#3730a3;margin-bottom:16px}
|
||||
.chart-row{display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:24px}
|
||||
.export-bar{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap}
|
||||
.btn{padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;border:none}
|
||||
.btn-primary{background:#4f46e5;color:#fff}
|
||||
.btn-outline{background:#fff;color:#4f46e5;border:1.5px solid #4f46e5}
|
||||
.btn:hover{opacity:0.9}
|
||||
.forecast-box{background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;border-radius:12px;padding:18px 22px}
|
||||
.forecast-box .label{font-size:10px;opacity:0.8;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
|
||||
.forecast-box .value{font-size:32px;font-weight:700;line-height:1}
|
||||
.forecast-box .unit{font-size:11px;opacity:0.7;margin-top:4px}
|
||||
.meta{font-size:11px;color:#9ca3af;margin-top:24px;text-align:center}
|
||||
@media(max-width:700px){.chart-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<span class="logo">⬡ PeerCortex <span>Network Intelligence</span></span>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/aspa-adoption">ASPA Tracker</a>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<h1>ASPA Adoption Tracker</h1>
|
||||
<p class="subtitle">Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card ${coverageNow >= 15 ? 'green' : 'amber'}">
|
||||
<div class="label">Atlas Coverage</div>
|
||||
<div class="value">${coverageNow.toFixed(1)}%</div>
|
||||
<div class="unit">of Atlas-visible ASNs</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">ASPA Objects</div>
|
||||
<div class="value">${aspaNow.toLocaleString()}</div>
|
||||
<div class="unit">total in RPKI</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Atlas ASNs</div>
|
||||
<div class="value">${atlasTotal.toLocaleString()}</div>
|
||||
<div class="unit">sampled denominator</div>
|
||||
</div>
|
||||
<div class="card ${change7d > 0 ? 'green' : ''}">
|
||||
<div class="label">7-Day Delta</div>
|
||||
<div class="value">${change7d > 0 ? '+' : ''}${change7d}</div>
|
||||
<div class="unit">new ASPA objects</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Data Points</div>
|
||||
<div class="value">${aspaAdoptionDailyHistory.length}</div>
|
||||
<div class="unit">history snapshots</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="export-bar">
|
||||
<a class="btn btn-primary" href="/api/aspa-adoption-stats/export?format=json&period=30d">↓ JSON (30d)</a>
|
||||
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=30d">↓ CSV (30d)</a>
|
||||
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=1y">↓ CSV (1y)</a>
|
||||
<a class="btn btn-outline" href="/api/export/pdf?asn=0&format=report" style="display:none">PDF Report</a>
|
||||
</div>
|
||||
|
||||
<div class="chart-row">
|
||||
<div class="chart-wrap">
|
||||
<h2>Atlas Coverage % (last 30 days)</h2>
|
||||
<canvas id="coverageChart" height="120"></canvas>
|
||||
</div>
|
||||
<div class="forecast-box">
|
||||
<div class="label">90-Day Forecast</div>
|
||||
<div class="value">${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}</div>
|
||||
<div class="unit">linear projection from 30-day trend</div>
|
||||
<div style="margin-top:16px;font-size:11px;opacity:0.8">
|
||||
Based on ${trend30.length} data points.<br/>
|
||||
Current: ${coverageNow.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-wrap">
|
||||
<h2>Total ASPA Objects in RPKI (last 30 days)</h2>
|
||||
<canvas id="asnChart" height="80"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
Last snapshot: ${latest?.date ?? 'none'} · Denominator: ${atlasTotal.toLocaleString()} Atlas ASNs ·
|
||||
<a href="/api/aspa-adoption-stats?period=30d" style="color:#4f46e5">JSON API</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const labels = ${JSON.stringify(trendLabels)};
|
||||
const covData = ${JSON.stringify(trendValues)};
|
||||
const asnData = ${JSON.stringify(asnValues)};
|
||||
|
||||
const commonOpts = {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: '#f0eeff' }, ticks: { maxRotation: 45, font: { size: 10 } } },
|
||||
y: { grid: { color: '#f0eeff' }, ticks: { font: { size: 10 } }, beginAtZero: false }
|
||||
}
|
||||
};
|
||||
|
||||
new Chart(document.getElementById('coverageChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: covData,
|
||||
borderColor: '#4f46e5',
|
||||
backgroundColor: 'rgba(79,70,229,0.08)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: { ...commonOpts, scales: { ...commonOpts.scales, y: { ...commonOpts.scales.y, ticks: { callback: v => v + '%', font: { size: 10 } } } } }
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('asnChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: asnData,
|
||||
backgroundColor: 'rgba(79,70,229,0.6)',
|
||||
borderColor: '#4f46e5',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
},
|
||||
options: commonOpts
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
aspaAdoptionDailyHistory,
|
||||
loadAspaAdoptionHistory,
|
||||
saveAspaAdoptionHistory,
|
||||
recordAspaAdoptionSnapshot,
|
||||
getAdoptionTrend,
|
||||
buildAspaAdoptionDashboard,
|
||||
};
|
||||
224
server/aspa-verification/engine.js
Normal file
224
server/aspa-verification/engine.js
Normal file
@ -0,0 +1,224 @@
|
||||
// RFC-Compliant ASPA Verification Engine
|
||||
// Pure functions, no I/O -- see draft-ietf-sidrops-aspa-verification.
|
||||
|
||||
// Check if AS path contains AS_SET segments (curly braces indicate sets)
|
||||
function hasAsSet(asPath) {
|
||||
if (typeof asPath === "string") {
|
||||
return asPath.includes("{") || asPath.includes("}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hop Check function (core of ASPA verification)
|
||||
// aspaStore = Map<number, Set<number>> (CAS -> provider set)
|
||||
// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
|
||||
// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
|
||||
// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
|
||||
function hopCheck(asI, asJ, aspaStore) {
|
||||
const providers = aspaStore.get(asI);
|
||||
if (!providers) return "NoAttestation";
|
||||
return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
|
||||
}
|
||||
|
||||
// Collapse AS path prepends (remove consecutive duplicates)
|
||||
function collapsePrepends(path) {
|
||||
return path.filter((as, i) => i === 0 || as !== path[i - 1]);
|
||||
}
|
||||
|
||||
// Upstream Verification (RFC Section 6.1)
|
||||
function verifyUpstream(asPath, aspaStore, rawPathStr) {
|
||||
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
||||
const collapsed = collapsePrepends(asPath);
|
||||
if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };
|
||||
|
||||
const hops = [];
|
||||
let hasNoAttestation = false;
|
||||
|
||||
for (let i = 1; i < collapsed.length; i++) {
|
||||
// collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to
|
||||
// the validator) is the provider it's declaring -- customer goes first in hopCheck.
|
||||
const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
||||
hops.push({
|
||||
from: collapsed[i - 1],
|
||||
to: collapsed[i],
|
||||
result: check,
|
||||
});
|
||||
if (check === "NotProviderPlus") {
|
||||
return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
|
||||
}
|
||||
if (check === "NoAttestation") hasNoAttestation = true;
|
||||
}
|
||||
|
||||
return {
|
||||
result: hasNoAttestation ? "Unknown" : "Valid",
|
||||
reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
// Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5.
|
||||
// Unlike upstream verification, a downstream path may legitimately contain both
|
||||
// an up-ramp (customer->provider hops, near the origin) AND a down-ramp
|
||||
// (provider->customer hops, near the validator), transitioning at most once --
|
||||
// e.g. origin -> its providers -> a peering exchange -> validator's providers ->
|
||||
// validator. Section 5.3 defines four ramp lengths computed from two independent
|
||||
// scans, verified 2026-07-16 against a worked example fetched from the draft
|
||||
// (N=4 path with a single unattested/no-attestation "kink" correctly resolves
|
||||
// to Valid, and a path with definite failures on both ends correctly resolves
|
||||
// to Invalid, matching this implementation).
|
||||
function verifyDownstream(asPath, aspaStore, rawPathStr) {
|
||||
if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
|
||||
const collapsed = collapsePrepends(asPath);
|
||||
const N = collapsed.length;
|
||||
if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };
|
||||
|
||||
const hops = [];
|
||||
for (let i = 1; i < N; i++) {
|
||||
hops.push({
|
||||
from: collapsed[i - 1],
|
||||
to: collapsed[i],
|
||||
result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore),
|
||||
});
|
||||
}
|
||||
|
||||
// Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
|
||||
// (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
|
||||
// maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
|
||||
// minUpRamp stops at the first hop that isn't confirmed ProviderPlus
|
||||
// (including unattested "NoAttestation" hops, which don't disprove a leak
|
||||
// but also don't prove the path is clean).
|
||||
let maxUpRamp = N;
|
||||
let minUpRamp = N;
|
||||
for (let k = N - 1; k >= 1; k--) {
|
||||
const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore);
|
||||
const I = N - k;
|
||||
if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I;
|
||||
if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I;
|
||||
if (maxUpRamp !== N && minUpRamp !== N) break;
|
||||
}
|
||||
|
||||
// Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
|
||||
// toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
|
||||
let maxDownRamp = N;
|
||||
let minDownRamp = N;
|
||||
for (let k = 0; k <= N - 2; k++) {
|
||||
const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore);
|
||||
const rampLen = k + 1; // = N - J + 1 for the corresponding J
|
||||
if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen;
|
||||
if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen;
|
||||
if (maxDownRamp !== N && minDownRamp !== N) break;
|
||||
}
|
||||
|
||||
if (maxUpRamp + maxDownRamp < N) {
|
||||
return {
|
||||
result: "Invalid",
|
||||
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
|
||||
hops,
|
||||
};
|
||||
}
|
||||
if (minUpRamp + minDownRamp < N) {
|
||||
return {
|
||||
result: "Unknown",
|
||||
reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
|
||||
hops,
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: "Valid",
|
||||
reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
// Valley Detection: scan path for up-down-up pattern (route leak indicator)
|
||||
function detectValleys(asPath, aspaStore) {
|
||||
const collapsed = collapsePrepends(asPath);
|
||||
if (collapsed.length < 4) return [];
|
||||
|
||||
const valleys = [];
|
||||
// Walk the path and look at relationship transitions
|
||||
const relationships = [];
|
||||
for (let i = 1; i < collapsed.length; i++) {
|
||||
const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
|
||||
const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
|
||||
let rel = "unknown";
|
||||
if (fwd === "ProviderPlus") rel = "customer-to-provider";
|
||||
else if (rev === "ProviderPlus") rel = "provider-to-customer";
|
||||
else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
|
||||
relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
|
||||
}
|
||||
|
||||
// Detect c2p -> p2c -> c2p pattern
|
||||
for (let i = 0; i < relationships.length - 2; i++) {
|
||||
if (
|
||||
relationships[i].rel === "customer-to-provider" &&
|
||||
relationships[i + 1].rel === "provider-to-customer" &&
|
||||
relationships[i + 2].rel === "customer-to-provider"
|
||||
) {
|
||||
valleys.push({
|
||||
position: i,
|
||||
path_segment: [
|
||||
relationships[i].from,
|
||||
relationships[i].to,
|
||||
relationships[i + 1].to,
|
||||
relationships[i + 2].to,
|
||||
].map((a) => "AS" + a),
|
||||
description:
|
||||
"Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
|
||||
" (c2p) -> AS" + relationships[i + 1].to +
|
||||
" (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return valleys;
|
||||
}
|
||||
|
||||
// Build ASPA store from detected provider relationships
|
||||
function buildAspaStore(detectedProviders, targetAsn) {
|
||||
const store = new Map();
|
||||
// Add the target ASN's providers
|
||||
if (detectedProviders.length > 0) {
|
||||
const providerSet = new Set(detectedProviders.map((p) => p.asn));
|
||||
store.set(targetAsn, providerSet);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
// Calculate ASPA Readiness Score (0-100)
|
||||
function calculateAspaReadinessScore(params) {
|
||||
const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;
|
||||
|
||||
// ROA coverage (0-25 points)
|
||||
const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);
|
||||
|
||||
// ASPA object exists (0-25 points)
|
||||
const aspaScore = aspaObjectExists ? 25 : 0;
|
||||
|
||||
// Provider completeness (0-25 points)
|
||||
const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);
|
||||
|
||||
// Path validation results (0-25 points)
|
||||
const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);
|
||||
|
||||
return {
|
||||
total: roaScore + aspaScore + provScore + pathScore,
|
||||
breakdown: {
|
||||
roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
|
||||
aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
|
||||
provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
|
||||
path_validation: { score: pathScore, max: 25, value: pathValidationPct },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasAsSet,
|
||||
hopCheck,
|
||||
collapsePrepends,
|
||||
verifyUpstream,
|
||||
verifyDownstream,
|
||||
detectValleys,
|
||||
buildAspaStore,
|
||||
calculateAspaReadinessScore,
|
||||
};
|
||||
77
server/atlas-probes.js
Normal file
77
server/atlas-probes.js
Normal file
@ -0,0 +1,77 @@
|
||||
const { fetchJSON } = require("./services/http-helpers");
|
||||
|
||||
// Atlas Probe Cache (for Lia's Atlas Paradise). Exposed as a mutable state
|
||||
// object (not a bare reassigned export) so every requirer always sees the
|
||||
// current value after fetchAllAtlasProbes() resolves -- a plain
|
||||
// `module.exports = { atlasProbeCache }` would only capture the value (null)
|
||||
// at require time, since CommonJS destructuring doesn't track reassignment.
|
||||
const atlasState = {
|
||||
cache: null,
|
||||
fetching: false,
|
||||
};
|
||||
|
||||
function fetchAllAtlasProbes() {
|
||||
if (atlasState.fetching) return Promise.resolve();
|
||||
atlasState.fetching = true;
|
||||
console.log("[ATLAS] Fetching all Atlas probes...");
|
||||
|
||||
return new Promise(function(resolve) {
|
||||
var allAsns = new Set();
|
||||
var byCountry = {};
|
||||
var pageCount = 0;
|
||||
var maxPages = 40;
|
||||
|
||||
function fetchPage(pageUrl) {
|
||||
if (pageCount >= maxPages) return finish();
|
||||
pageCount++;
|
||||
|
||||
fetchJSON(pageUrl).then(function(data) {
|
||||
if (!data || !data.results) return finish();
|
||||
|
||||
data.results.forEach(function(probe) {
|
||||
var asn4 = probe.asn_v4;
|
||||
var asn6 = probe.asn_v6;
|
||||
var cc = probe.country_code || "XX";
|
||||
|
||||
if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
|
||||
byCountry[cc].total++;
|
||||
if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
|
||||
if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
|
||||
if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
|
||||
});
|
||||
|
||||
if (data.next) {
|
||||
fetchPage(data.next);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
}).catch(function() { finish(); });
|
||||
}
|
||||
|
||||
function finish() {
|
||||
var byCountryOut = {};
|
||||
Object.keys(byCountry).forEach(function(cc) {
|
||||
var info = byCountry[cc];
|
||||
byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
|
||||
});
|
||||
|
||||
atlasState.cache = {
|
||||
total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
|
||||
total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
|
||||
unique_asns_with_probes: allAsns.size,
|
||||
asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
|
||||
by_country: byCountryOut,
|
||||
fetched_at: new Date().toISOString(),
|
||||
pages_fetched: pageCount,
|
||||
};
|
||||
|
||||
console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
|
||||
atlasState.fetching = false;
|
||||
resolve();
|
||||
}
|
||||
|
||||
fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { atlasState, fetchAllAtlasProbes };
|
||||
53
server/bgp-he-net.js
Normal file
53
server/bgp-he-net.js
Normal file
@ -0,0 +1,53 @@
|
||||
const { fetchHTML } = require("./services/http-helpers");
|
||||
|
||||
// Feature 24: bgp.he.net Integration.
|
||||
// NOTE: currently dead code in practice -- /api/lookup's only call site
|
||||
// (`timedFetch("bgp.he.net", ...)`) is hardcoded to `Promise.resolve(null)`
|
||||
// rather than actually invoking this. Moved as-is (not removed) since that's
|
||||
// a "disabled pending re-enable" state, not a provably-unreachable one --
|
||||
// removing a whole scraping feature is a bigger decision than this
|
||||
// behavior-preserving extraction pass should make unilaterally.
|
||||
async function fetchBgpHeNet(asn) {
|
||||
try {
|
||||
const html = await fetchHTML("https://bgp.he.net/AS" + asn);
|
||||
if (!html) return null;
|
||||
const result = {};
|
||||
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
||||
if (titleMatch) result.title = titleMatch[1].trim();
|
||||
const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
|
||||
if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, ''));
|
||||
const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i);
|
||||
if (countryMatch) result.country = countryMatch[1].trim();
|
||||
// Extract 2-letter country code from href="/country/XX"
|
||||
const ccMatch = html.match(/href="\/country\/([A-Z]{2})"/i);
|
||||
if (ccMatch) result.country_code = ccMatch[1].toUpperCase();
|
||||
// Extract clean AS name from title: "AS12345 Some Name - bgp.he.net" → "Some Name"
|
||||
if (titleMatch) {
|
||||
const rawTitle = titleMatch[1].trim();
|
||||
const nameFromTitle = rawTitle.replace(/^AS\d+\s+/i, '').replace(/\s+-\s+bgp\.he\.net.*$/i, '').trim();
|
||||
if (nameFromTitle && !nameFromTitle.toLowerCase().includes('bgp.he.net')) {
|
||||
result.name_from_title = nameFromTitle;
|
||||
}
|
||||
}
|
||||
|
||||
const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i);
|
||||
if (lgMatch) result.looking_glass = lgMatch[1];
|
||||
const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
|
||||
if (descMatch) result.description = descMatch[1].trim();
|
||||
const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
|
||||
if (irrMatch) result.irr_record = irrMatch[1].trim();
|
||||
// bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147"
|
||||
const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
|
||||
if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
|
||||
const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
|
||||
if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
|
||||
const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
|
||||
if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
|
||||
result.source_url = "https://bgp.he.net/AS" + asn;
|
||||
return result;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fetchBgpHeNet };
|
||||
85
server/caches/pdb-source-cache.js
Normal file
85
server/caches/pdb-source-cache.js
Normal file
@ -0,0 +1,85 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
|
||||
// Eliminates redundant PDB API calls under load
|
||||
const pdbSourceCache = {
|
||||
net: new Map(), // key: asn string → {data, ts}
|
||||
netixlan: new Map(), // key: net_id string → {data, ts}
|
||||
netfac: new Map(), // key: net_id string → {data, ts}
|
||||
facCoords: new Map(), // key: fac_id string → {lat, lon, ts}
|
||||
TTL_NET: 6 * 60 * 60 * 1000, // 6 hours
|
||||
TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours
|
||||
TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
MAX_NET: 5000,
|
||||
MAX_IXFAC: 5000,
|
||||
MAX_COORDS: 10000,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
|
||||
get(type, key) {
|
||||
const map = this[type];
|
||||
const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC);
|
||||
const entry = map.get(String(key));
|
||||
if (!entry) { this.misses++; return null; }
|
||||
if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; }
|
||||
this.hits++;
|
||||
return entry.data;
|
||||
},
|
||||
|
||||
set(type, key, data) {
|
||||
const map = this[type];
|
||||
const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC);
|
||||
if (map.size >= max) {
|
||||
// Evict oldest entry (Map preserves insertion order)
|
||||
map.delete(map.keys().next().value);
|
||||
}
|
||||
map.set(String(key), { data, ts: Date.now() });
|
||||
},
|
||||
|
||||
// Disk persistence
|
||||
saveToDisk(filePath) {
|
||||
try {
|
||||
const serialize = (map) => {
|
||||
const obj = {};
|
||||
for (const [k, v] of map) obj[k] = v;
|
||||
return obj;
|
||||
};
|
||||
const data = JSON.stringify({
|
||||
ts: Date.now(),
|
||||
net: serialize(this.net),
|
||||
netixlan: serialize(this.netixlan),
|
||||
netfac: serialize(this.netfac),
|
||||
facCoords: serialize(this.facCoords),
|
||||
});
|
||||
fs.writeFileSync(filePath, data);
|
||||
console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
|
||||
} catch (e) {
|
||||
console.warn("[PDB-CACHE] Disk save failed:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
loadFromDisk(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
const now = Date.now();
|
||||
const load = (map, obj, ttl) => {
|
||||
for (const [k, v] of Object.entries(obj || {})) {
|
||||
if (now - v.ts < ttl) map.set(k, v);
|
||||
}
|
||||
};
|
||||
load(this.net, data.net, this.TTL_NET);
|
||||
load(this.netixlan, data.netixlan, this.TTL_IXFAC);
|
||||
load(this.netfac, data.netfac, this.TTL_IXFAC);
|
||||
load(this.facCoords, data.facCoords, this.TTL_COORDS);
|
||||
console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[PDB-CACHE] Disk load failed:", e.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { pdbSourceCache };
|
||||
113
server/caches/response-caches.js
Normal file
113
server/caches/response-caches.js
Normal file
@ -0,0 +1,113 @@
|
||||
// Task 6: In-memory cache with TTL + Rate Limiting
|
||||
const responseCache = new Map();
|
||||
|
||||
function cacheGet(key) {
|
||||
const entry = responseCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expires) {
|
||||
responseCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function cacheSet(key, data, ttlMs) {
|
||||
responseCache.set(key, { data, expires: Date.now() + ttlMs });
|
||||
// Evict old entries periodically (keep cache under 500 entries)
|
||||
if (responseCache.size > 500) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of responseCache) {
|
||||
if (now > v.expires) responseCache.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
|
||||
const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts
|
||||
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN
|
||||
const rdapCache = new Map(); // key: asn string, value: { data, ts }
|
||||
const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function rdapCacheGet(asn) {
|
||||
const e = rdapCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data;
|
||||
return undefined; // undefined = not cached, null = cached miss
|
||||
}
|
||||
function rdapCacheSet(asn, data) {
|
||||
if (rdapCache.size > 5000) {
|
||||
rdapCache.delete(rdapCache.keys().next().value);
|
||||
}
|
||||
rdapCache.set(String(asn), { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering
|
||||
const whoisCache = new Map(); // key: asn string, value: { data, ts, ttl }
|
||||
const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
// A "not found" result after all 5 RIR sources failed is far more often a shared
|
||||
// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real
|
||||
// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't
|
||||
// tell timeout/network-error apart from a genuine 404, so cache that specific
|
||||
// conclusion much more briefly than a confirmed, parsed WHOIS record.
|
||||
const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000;
|
||||
function whoisCacheGet(asn) {
|
||||
const e = whoisCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function whoisCacheSet(asn, data, ttl) {
|
||||
if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
|
||||
whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL });
|
||||
}
|
||||
|
||||
// Quick-IX Cache — 1h TTL, for Peering Recommendations
|
||||
const quickIxCache = new Map(); // key: asn string, value: { data, ts }
|
||||
const QUICK_IX_CACHE_TTL = 60 * 60 * 1000;
|
||||
function quickIxCacheGet(asn) {
|
||||
const e = quickIxCache.get(String(asn));
|
||||
if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function quickIxCacheSet(asn, data) {
|
||||
if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value);
|
||||
quickIxCache.set(String(asn), { data, ts: Date.now() });
|
||||
}
|
||||
|
||||
// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs
|
||||
const bgproutesResultCache = new Map();
|
||||
const aspaResultCache = new Map();
|
||||
const validateResultCache = new Map();
|
||||
const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
|
||||
function resultCacheGet(map, key) {
|
||||
const e = map.get(String(key));
|
||||
const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL;
|
||||
if (e && (Date.now() - e.ts) < ttl) return e.data;
|
||||
return undefined;
|
||||
}
|
||||
function resultCacheSet(map, key, data, ttlMs) {
|
||||
if (map.size > 2000) map.delete(map.keys().next().value);
|
||||
map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
responseCache,
|
||||
cacheGet,
|
||||
cacheSet,
|
||||
RESULT_CACHE_TTL,
|
||||
CACHE_TTL_LOOKUP,
|
||||
CACHE_TTL_LOOKUP_DEGRADED,
|
||||
CACHE_TTL_DEFAULT,
|
||||
rdapCacheGet,
|
||||
rdapCacheSet,
|
||||
whoisCacheGet,
|
||||
whoisCacheSet,
|
||||
WHOIS_NOT_FOUND_CACHE_TTL,
|
||||
quickIxCacheGet,
|
||||
quickIxCacheSet,
|
||||
bgproutesResultCache,
|
||||
aspaResultCache,
|
||||
validateResultCache,
|
||||
resultCacheGet,
|
||||
resultCacheSet,
|
||||
};
|
||||
225
server/caches/roa-store.js
Normal file
225
server/caches/roa-store.js
Normal file
@ -0,0 +1,225 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// Local ROA Store — validates prefixes without RIPE Stat API calls.
|
||||
// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA.
|
||||
// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix)
|
||||
const roaStore = {
|
||||
v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start
|
||||
v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex
|
||||
ready: false,
|
||||
count: 0,
|
||||
lastBuild: 0,
|
||||
|
||||
// Parse IPv4 prefix string to 32-bit unsigned integer
|
||||
_ipv4ToUint32(ip) {
|
||||
const parts = ip.split(".");
|
||||
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
},
|
||||
|
||||
// Build ROA store from Cloudflare feed roas array
|
||||
build(roas) {
|
||||
const v4 = [];
|
||||
const v6 = [];
|
||||
for (let i = 0; i < roas.length; i++) {
|
||||
const r = roas[i];
|
||||
const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn);
|
||||
const prefix = r.prefix;
|
||||
const maxLen = r.maxLength || r.maxPrefixLength || 0;
|
||||
if (!prefix || !asn) continue;
|
||||
|
||||
const slashIdx = prefix.indexOf("/");
|
||||
if (slashIdx < 0) continue;
|
||||
const prefixLen = parseInt(prefix.substring(slashIdx + 1));
|
||||
const addr = prefix.substring(0, slashIdx);
|
||||
|
||||
if (prefix.indexOf(":") >= 0) {
|
||||
// IPv6 — store as zero-padded hex string for sorting
|
||||
const expanded = this._expandIPv6(addr);
|
||||
if (expanded) {
|
||||
v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen });
|
||||
}
|
||||
} else {
|
||||
// IPv4 — store as numeric range
|
||||
const start = this._ipv4ToUint32(addr);
|
||||
const hostBits = 32 - prefixLen;
|
||||
const end = (start | ((1 << hostBits) - 1)) >>> 0;
|
||||
v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort for binary search
|
||||
v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen);
|
||||
v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen);
|
||||
|
||||
this.v4Entries = v4;
|
||||
this.v6Entries = v6;
|
||||
this.count = v4.length + v6.length;
|
||||
this.ready = true;
|
||||
this.lastBuild = Date.now();
|
||||
},
|
||||
|
||||
// Expand IPv6 address to 32-char hex for reliable sorting
|
||||
_expandIPv6(addr) {
|
||||
try {
|
||||
let groups = addr.split("::");
|
||||
let left = groups[0] ? groups[0].split(":") : [];
|
||||
let right = groups.length > 1 && groups[1] ? groups[1].split(":") : [];
|
||||
const missing = 8 - left.length - right.length;
|
||||
const mid = [];
|
||||
for (let i = 0; i < missing; i++) mid.push("0000");
|
||||
const all = [...left, ...mid, ...right];
|
||||
return all.map(g => g.padStart(4, "0")).join("");
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Validate a prefix against the local ROA store
|
||||
// Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N}
|
||||
validate(asn, prefix) {
|
||||
if (!this.ready) return null; // Signal caller to use fallback
|
||||
|
||||
const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn);
|
||||
const slashIdx = prefix.indexOf("/");
|
||||
if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 };
|
||||
const prefixLen = parseInt(prefix.substring(slashIdx + 1));
|
||||
const addr = prefix.substring(0, slashIdx);
|
||||
|
||||
if (prefix.indexOf(":") >= 0) {
|
||||
return this._validateV6(asnNum, addr, prefixLen, prefix);
|
||||
}
|
||||
return this._validateV4(asnNum, addr, prefixLen, prefix);
|
||||
},
|
||||
|
||||
_validateV4(asn, addr, prefixLen, prefix) {
|
||||
const queryStart = this._ipv4ToUint32(addr);
|
||||
const entries = this.v4Entries;
|
||||
|
||||
// Binary search: find rightmost entry where start <= queryStart
|
||||
let lo = 0, hi = entries.length - 1;
|
||||
let insertionPoint = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (entries[mid].start <= queryStart) {
|
||||
insertionPoint = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from insertion point to find covering ROAs
|
||||
// ROAs are sorted by start, so we scan back while start could still cover our prefix
|
||||
const matched = [];
|
||||
const unmatchedAs = [];
|
||||
for (let i = insertionPoint; i >= 0; i--) {
|
||||
const e = entries[i];
|
||||
// If this ROA's network start is too far left, no more matches possible
|
||||
if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away
|
||||
// Check if query prefix is contained within this ROA
|
||||
if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) {
|
||||
if (prefixLen <= e.maxLen) {
|
||||
if (e.asn === asn) {
|
||||
matched.push(e);
|
||||
} else {
|
||||
unmatchedAs.push(e);
|
||||
}
|
||||
}
|
||||
// prefixLen > maxLen → too specific, invalid if ASN matches
|
||||
else if (e.asn === asn) {
|
||||
unmatchedAs.push(e); // ASN matches but length exceeds maxLen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
|
||||
if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
},
|
||||
|
||||
_validateV6(asn, addr, prefixLen, prefix) {
|
||||
const queryHex = this._expandIPv6(addr);
|
||||
if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 };
|
||||
const entries = this.v6Entries;
|
||||
|
||||
// Binary search for approximate position
|
||||
let lo = 0, hi = entries.length - 1;
|
||||
let insertionPoint = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (entries[mid].prefixHex <= queryHex) {
|
||||
insertionPoint = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const matched = [];
|
||||
const unmatchedAs = [];
|
||||
// Scan backwards from insertion point
|
||||
for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) {
|
||||
const e = entries[i];
|
||||
// Check if query prefix is covered by this ROA entry
|
||||
// A covering ROA has a shorter or equal prefix length and its network prefix matches
|
||||
if (e.prefixLen <= prefixLen) {
|
||||
// Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up)
|
||||
const hexChars = Math.ceil(e.prefixLen / 4);
|
||||
if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) {
|
||||
if (prefixLen <= e.maxLen) {
|
||||
if (e.asn === asn) matched.push(e);
|
||||
else unmatchedAs.push(e);
|
||||
} else if (e.asn === asn) {
|
||||
unmatchedAs.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stop if we're too far away
|
||||
if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break;
|
||||
}
|
||||
|
||||
if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
|
||||
if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
|
||||
return { prefix, status: "not_found", validating_roas: 0 };
|
||||
},
|
||||
|
||||
// Persist to disk for fast restart
|
||||
saveToDisk(filePath) {
|
||||
try {
|
||||
const data = JSON.stringify({
|
||||
ts: this.lastBuild,
|
||||
v4Count: this.v4Entries.length,
|
||||
v6Count: this.v6Entries.length,
|
||||
v4: this.v4Entries,
|
||||
v6: this.v6Entries,
|
||||
});
|
||||
fs.writeFileSync(filePath, data);
|
||||
console.log("[ROA] Saved " + this.count + " ROAs to disk");
|
||||
} catch (e) {
|
||||
console.warn("[ROA] Disk save failed:", e.message);
|
||||
}
|
||||
},
|
||||
|
||||
// Load from disk cache (returns true if loaded)
|
||||
loadFromDisk(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
// Only use if less than 6 hours old
|
||||
if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false;
|
||||
this.v4Entries = data.v4;
|
||||
this.v6Entries = data.v6;
|
||||
this.count = data.v4Count + data.v6Count;
|
||||
this.lastBuild = data.ts;
|
||||
this.ready = true;
|
||||
console.log("[ROA] Loaded " + this.count + " ROAs from disk cache");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[ROA] Disk load failed:", e.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { roaStore };
|
||||
26
server/data/city-coords.js
Normal file
26
server/data/city-coords.js
Normal file
@ -0,0 +1,26 @@
|
||||
// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
|
||||
const CITY_COORDS = {
|
||||
"amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821],
|
||||
"paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417],
|
||||
"berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820],
|
||||
"vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122],
|
||||
"copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384],
|
||||
"milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393],
|
||||
"dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025],
|
||||
"sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784],
|
||||
"moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198],
|
||||
"hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093],
|
||||
"los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298],
|
||||
"dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874],
|
||||
"seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832],
|
||||
"sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940],
|
||||
"manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735],
|
||||
"nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052],
|
||||
"auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903],
|
||||
"atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207],
|
||||
"montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780],
|
||||
"taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456],
|
||||
"scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
|
||||
};
|
||||
|
||||
module.exports = { CITY_COORDS };
|
||||
3
server/data/constants.js
Normal file
3
server/data/constants.js
Normal file
@ -0,0 +1,3 @@
|
||||
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
|
||||
|
||||
module.exports = { UA };
|
||||
4
server/data/ix-city-map.js
Normal file
4
server/data/ix-city-map.js
Normal file
@ -0,0 +1,4 @@
|
||||
// Small ASN/IX-id → city fallback map used by the /api/lookup facility geocoder
|
||||
const IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
|
||||
|
||||
module.exports = { IX_CITY_MAP };
|
||||
6
server/data/rir-country-codes.js
Normal file
6
server/data/rir-country-codes.js
Normal file
@ -0,0 +1,6 @@
|
||||
const ARIN_CC = new Set(["US","CA","AI","AG","BS","BB","BZ","VG","KY","DM","DO","GD","GP","HT","JM","MQ","MS","PR","KN","LC","VC","TT","TC","VI","UM"]);
|
||||
const APNIC_CC = new Set(["AU","NZ","JP","CN","KR","IN","HK","SG","TW","VN","TH","ID","MY","PK","BD","LK","NP","PH","AF","KH","LA","MM","MN","BT","BN","FJ","PG","WS","TO","VU","SB","KI","NR","TV","FM","MH","PW","CK","NU","TK","WF","PF","NC","GU","MP","AS","CC","CX","HM","NF"]);
|
||||
const LACNIC_CC = new Set(["BR","AR","MX","CO","CL","PE","VE","EC","UY","BO","PY","CU","GT","HN","SV","NI","CR","PA","GY","SR","GF","AW","CW","SX","BQ","AN"]);
|
||||
const AFRINIC_CC = new Set(["ZA","NG","KE","EG","GH","TZ","UG","MA","CI","SN","ZM","ZW","AO","MZ","CM","ET","SD","MG","DZ","TN","LY","RW","NA","BW","MW","ML","BF","NE","GN","TD","SO","LS","SZ","ER","DJ","GM","SL","LR","TG","BJ","GW","CF","CG","CD","GQ","ST","KM","MR","SC","MU","RE","CV","BU","SS","EH"]);
|
||||
|
||||
module.exports = { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC };
|
||||
9
server/data/rir-delegation-urls.js
Normal file
9
server/data/rir-delegation-urls.js
Normal file
@ -0,0 +1,9 @@
|
||||
const RIR_DELEGATION_URLS = {
|
||||
RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
|
||||
ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
|
||||
APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
|
||||
AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest',
|
||||
LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
|
||||
};
|
||||
|
||||
module.exports = { RIR_DELEGATION_URLS };
|
||||
26
server/data/tier1-asns.js
Normal file
26
server/data/tier1-asns.js
Normal file
@ -0,0 +1,26 @@
|
||||
// Tier-1 ASN Set (used for route leak heuristics)
|
||||
const TIER1_ASNS = new Set([
|
||||
174, // Cogent
|
||||
209, // CenturyLink/Lumen
|
||||
286, // KPN
|
||||
701, // Verizon/UUNET
|
||||
702, // Verizon
|
||||
1239, // Sprint
|
||||
1273, // Vodafone
|
||||
1280, // Internet Systems Consortium
|
||||
1299, // Arelion (Telia)
|
||||
2914, // NTT
|
||||
3257, // GTT
|
||||
3320, // Deutsche Telekom
|
||||
3356, // Lumen (Level3)
|
||||
3491, // PCCW
|
||||
5511, // Orange
|
||||
6453, // TATA
|
||||
6461, // Zayo
|
||||
6762, // Telecom Italia Sparkle
|
||||
7018, // AT&T
|
||||
7473, // SingTel
|
||||
12956, // Telxius
|
||||
]);
|
||||
|
||||
module.exports = { TIER1_ASNS };
|
||||
72
server/hijack-monitoring/monitor.js
Normal file
72
server/hijack-monitoring/monitor.js
Normal file
@ -0,0 +1,72 @@
|
||||
const crypto = require("crypto");
|
||||
const localDb = require("../../local-db-client");
|
||||
const { loadHijackSubs, loadHijackAlerts, saveHijackAlerts, saveHijackSubs } = require("./store");
|
||||
const { notifyWebhooks } = require("./webhooks");
|
||||
|
||||
// Classify hijack severity
|
||||
function classifyHijackSeverity(unexpected, missing, rpkiInvalid) {
|
||||
if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL';
|
||||
if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH';
|
||||
if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM';
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
// Returns null (not []) when the prefix lookup itself failed -- distinct from a
|
||||
// genuine zero-prefix result. runHijackCheck must not treat these the same way:
|
||||
// setting an empty baseline from a transient failure would permanently disable
|
||||
// hijack detection for that subscription (baseline.size stays 0 forever after,
|
||||
// so "unexpected" never fires again -- see the baseline.size > 0 guard below).
|
||||
async function checkHijacksForAsn(asn) {
|
||||
try {
|
||||
const data = await localDb.getRipeStatAnnouncedPrefixes(asn);
|
||||
if (data && data.status === 'error') return null;
|
||||
const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
|
||||
return prefixes;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
async function runHijackCheck() {
|
||||
const subs = loadHijackSubs();
|
||||
if (!subs.length) return;
|
||||
const alerts = loadHijackAlerts();
|
||||
let changed = false;
|
||||
for (const sub of subs) {
|
||||
const current = await checkHijacksForAsn(sub.asn);
|
||||
if (current === null) {
|
||||
// Lookup failed this cycle -- skip both anomaly detection and baseline
|
||||
// initialization; retry on the next 30-minute run rather than locking in
|
||||
// an empty baseline that would silently disable detection forever.
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
// Dedup: only alert if no alert in last 6 hours for this ASN
|
||||
const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
|
||||
const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo);
|
||||
if (!recentAlert) {
|
||||
const severity = classifyHijackSeverity(unexpected, missing, 0);
|
||||
const alert = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
asn: sub.asn, ts: new Date().toISOString(),
|
||||
severity, unexpected, missing, resolved: false,
|
||||
msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing`
|
||||
};
|
||||
alerts.push(alert);
|
||||
changed = true;
|
||||
notifyWebhooks(sub.asn, alert);
|
||||
}
|
||||
}
|
||||
// Set baseline on first monitoring
|
||||
if (!sub.prefixes || !sub.prefixes.length) {
|
||||
sub.prefixes = current;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); }
|
||||
}
|
||||
// Run hijack check every 30 minutes
|
||||
setInterval(runHijackCheck, 30 * 60 * 1000);
|
||||
|
||||
module.exports = { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck };
|
||||
31
server/hijack-monitoring/store.js
Normal file
31
server/hijack-monitoring/store.js
Normal file
@ -0,0 +1,31 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Repo root (where server.js itself lives) -- NOT this module's own directory.
|
||||
// Preserves the original server.js `__dirname` fallback behavior (only ever
|
||||
// exercised locally; Erik always has /opt/peercortex-app and takes that branch).
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..");
|
||||
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : REPO_ROOT);
|
||||
const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
|
||||
const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
|
||||
const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.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 []; } }
|
||||
function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
|
||||
function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
|
||||
function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} }
|
||||
function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
|
||||
|
||||
module.exports = {
|
||||
DATA_DIR,
|
||||
HIJACK_SUBS_FILE,
|
||||
HIJACK_ALERTS_FILE,
|
||||
WEBHOOK_SUBS_FILE,
|
||||
loadHijackSubs,
|
||||
loadHijackAlerts,
|
||||
loadWebhookSubs,
|
||||
saveWebhookSubs,
|
||||
saveHijackAlerts,
|
||||
saveHijackSubs,
|
||||
};
|
||||
82
server/hijack-monitoring/webhooks.js
Normal file
82
server/hijack-monitoring/webhooks.js
Normal file
@ -0,0 +1,82 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const crypto = require("crypto");
|
||||
const { loadWebhookSubs } = require("./store");
|
||||
|
||||
// Generate HMAC-SHA256 signature for webhook payloads
|
||||
function webhookSignature(secret, payload) {
|
||||
return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||
}
|
||||
|
||||
// Deliver webhook with exponential backoff (max 5 retries)
|
||||
async function deliverWebhook(sub, payload, attempt) {
|
||||
attempt = attempt || 1;
|
||||
const payloadStr = JSON.stringify(payload);
|
||||
const sig = webhookSignature(sub.secret, payloadStr);
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const parsed = new URL(sub.endpoint_url);
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payloadStr),
|
||||
'X-PeerCortex-Signature': sig,
|
||||
'X-PeerCortex-Event': 'hijack_detected',
|
||||
'User-Agent': 'PeerCortex-Webhook/1.0'
|
||||
},
|
||||
timeout: 10000
|
||||
};
|
||||
const req = lib.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', c => body += c);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ ok: true, status: res.statusCode });
|
||||
} else if (attempt < 5) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
|
||||
} else {
|
||||
resolve({ ok: false, status: res.statusCode, error: 'max_retries' });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', () => {
|
||||
if (attempt < 5) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
|
||||
} else {
|
||||
resolve({ ok: false, error: 'connection_failed' });
|
||||
}
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); });
|
||||
req.write(payloadStr);
|
||||
req.end();
|
||||
} catch(e) {
|
||||
resolve({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fire webhooks for all subscribers of an ASN when hijack detected
|
||||
async function notifyWebhooks(asn, alert) {
|
||||
const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false);
|
||||
for (const sub of subs) {
|
||||
const payload = {
|
||||
event: 'hijack_detected',
|
||||
asn: asn,
|
||||
alert: alert,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'PeerCortex BGP Monitor'
|
||||
};
|
||||
deliverWebhook(sub, payload, 1).then(result => {
|
||||
if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { webhookSignature, deliverWebhook, notifyWebhooks };
|
||||
80
server/ipv6-adoption.js
Normal file
80
server/ipv6-adoption.js
Normal file
@ -0,0 +1,80 @@
|
||||
const { RIR_DELEGATION_URLS } = require("./data/rir-delegation-urls");
|
||||
|
||||
// FEATURE 4: IPv6 Adoption per RIR
|
||||
|
||||
// In-memory cache: { data, ts }
|
||||
let ipv6StatsCache = null;
|
||||
const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
|
||||
|
||||
// Fetch and parse one RIR delegation file.
|
||||
// Returns { ipv4, ipv6, asns } count objects.
|
||||
function fetchRirDelegation(rir, url) {
|
||||
return new Promise((resolve) => {
|
||||
const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
|
||||
const mod = url.startsWith('https') ? require('https') : require('http');
|
||||
const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
|
||||
if (res.statusCode !== 200) { resolve(result); return; }
|
||||
let buf = '';
|
||||
res.on('data', d => { buf += d; });
|
||||
res.on('end', () => {
|
||||
for (const line of buf.split('\n')) {
|
||||
if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
|
||||
const parts = line.split('|');
|
||||
if (parts.length < 7) continue;
|
||||
const type = parts[2];
|
||||
const status = parts[6].trim();
|
||||
const cc = parts[1];
|
||||
if (status !== 'allocated' && status !== 'assigned') continue;
|
||||
if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
|
||||
else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
|
||||
else if (type === 'asn') { result.asns++; }
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve(result));
|
||||
req.on('timeout', () => { req.destroy(); resolve(result); });
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all 5 RIR delegation files and aggregate IPv6 stats.
|
||||
// Cached for 24 hours.
|
||||
async function fetchIpv6AdoptionStats(force) {
|
||||
if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
|
||||
return ipv6StatsCache.data;
|
||||
}
|
||||
console.log('[IPv6] Fetching RIR delegation stats...');
|
||||
const results = await Promise.all(
|
||||
Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
|
||||
);
|
||||
const rirs = results.map(r => ({
|
||||
rir: r.rir,
|
||||
ipv4_records: r.ipv4,
|
||||
ipv6_records: r.ipv6,
|
||||
asn_records: r.asns,
|
||||
ipv6_pct: (r.ipv4 + r.ipv6) > 0
|
||||
? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
|
||||
: 0,
|
||||
countries_with_ipv6: r.ipv6_countries.size,
|
||||
countries_with_ipv4: r.ipv4_countries.size,
|
||||
}));
|
||||
const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
|
||||
const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
|
||||
const data = {
|
||||
fetched_at: new Date().toISOString(),
|
||||
global_ipv6_pct: total_ipv4 + total_ipv6 > 0
|
||||
? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
|
||||
: 0,
|
||||
total_ipv4_records: total_ipv4,
|
||||
total_ipv6_records: total_ipv6,
|
||||
by_rir: rirs,
|
||||
};
|
||||
ipv6StatsCache = { data, ts: Date.now() };
|
||||
console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Pre-fetch on startup (non-blocking)
|
||||
fetchIpv6AdoptionStats().catch(() => {});
|
||||
|
||||
module.exports = { fetchRirDelegation, fetchIpv6AdoptionStats };
|
||||
92
server/pdb-org-countries.js
Normal file
92
server/pdb-org-countries.js
Normal file
@ -0,0 +1,92 @@
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const { UA } = require("./data/constants");
|
||||
const { PEERINGDB_API_KEY } = require("./services/peeringdb");
|
||||
|
||||
// Repo root -- NOT this module's own directory. See hijack-monitoring/store.js
|
||||
// for the same __dirname-fallback fix applied for the same reason.
|
||||
const REPO_ROOT = path.join(__dirname, "..");
|
||||
|
||||
// PeeringDB Org → Country Cache (for Lia's Paradise). Exposed via a mutable
|
||||
// state object (not a bare reassigned export) for the same reason as
|
||||
// atlas-probes.js's atlasState -- the Map itself is reassigned wholesale on
|
||||
// refresh, not just mutated in place.
|
||||
const pdbOrgState = { map: new Map() }; // org_id → { country, name }
|
||||
|
||||
function fetchPdbOrgCountries() {
|
||||
var cacheFile = path.join(REPO_ROOT, ".pdb-org-cache.json");
|
||||
|
||||
// Try disk cache first (valid for 24h)
|
||||
try {
|
||||
var stat = fs.statSync(cacheFile);
|
||||
var ageHours = (Date.now() - stat.mtimeMs) / 3600000;
|
||||
if (ageHours < 24) {
|
||||
var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
||||
pdbOrgState.map = new Map(Object.entries(cached));
|
||||
console.log("[PDB-ORG] Loaded " + pdbOrgState.map.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)");
|
||||
return Promise.resolve();
|
||||
}
|
||||
} catch (_) { /* no cache or invalid */ }
|
||||
|
||||
console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)...");
|
||||
return new Promise(function(resolve) {
|
||||
var chunks = [];
|
||||
var req = https.get("https://www.peeringdb.com/api/org?status=ok&depth=0", {
|
||||
headers: {
|
||||
"User-Agent": UA,
|
||||
// Bug fix 2026-07-16: a bare `PEERINGDB_API_KEY ? ... : undefined`
|
||||
// sets the Authorization header value to `undefined` when no key is
|
||||
// configured. Node 22's stricter header validation throws
|
||||
// synchronously on that (ERR_HTTP_INVALID_HEADER_VALUE) instead of
|
||||
// omitting the header, crashing the whole process at startup via
|
||||
// this function's unhandled rejection (found empirically while
|
||||
// trying to boot server.js locally without a real PeeringDB key).
|
||||
// Conditionally spread the key in instead so the header key itself
|
||||
// is simply absent when unconfigured.
|
||||
...(PEERINGDB_API_KEY ? { "Authorization": "Api-Key " + PEERINGDB_API_KEY } : {}),
|
||||
},
|
||||
timeout: 120000,
|
||||
}, function(res) {
|
||||
if (res.statusCode !== 200) {
|
||||
console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
res.on("data", function(chunk) { chunks.push(chunk); });
|
||||
res.on("end", function() {
|
||||
try {
|
||||
var body = Buffer.concat(chunks).toString("utf8");
|
||||
var data = JSON.parse(body);
|
||||
if (data && data.data) {
|
||||
pdbOrgState.map = new Map();
|
||||
var cacheObj = {};
|
||||
data.data.forEach(function(o) {
|
||||
if (o.id && o.country) {
|
||||
pdbOrgState.map.set(o.id, { country: o.country, name: o.name || "" });
|
||||
cacheObj[o.id] = { country: o.country, name: o.name || "" };
|
||||
}
|
||||
});
|
||||
// Save to disk cache
|
||||
try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {}
|
||||
console.log("[PDB-ORG] Loaded " + pdbOrgState.map.size + " org→country mappings (cached to disk)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[PDB-ORG] Parse error:", e.message);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
req.on("error", function(e) {
|
||||
console.error("[PDB-ORG] Fetch error:", e.message);
|
||||
resolve();
|
||||
});
|
||||
req.on("timeout", function() {
|
||||
console.error("[PDB-ORG] Timeout after 120s");
|
||||
req.destroy();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { pdbOrgState, fetchPdbOrgCountries };
|
||||
65
server/pdf-export/renderer.js
Normal file
65
server/pdf-export/renderer.js
Normal file
@ -0,0 +1,65 @@
|
||||
// PDF Export: lazy puppeteer loader + response cache + renderer
|
||||
|
||||
// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
|
||||
let _puppeteerBrowser = null;
|
||||
let _puppeteerLoading = false;
|
||||
async function getPuppeteerBrowser() {
|
||||
if (_puppeteerBrowser) return _puppeteerBrowser;
|
||||
if (_puppeteerLoading) {
|
||||
// Wait for in-flight launch
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
return _puppeteerBrowser;
|
||||
}
|
||||
_puppeteerLoading = true;
|
||||
try {
|
||||
const puppeteer = require("puppeteer");
|
||||
_puppeteerBrowser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
||||
});
|
||||
console.log("[PDF] Puppeteer browser launched");
|
||||
_puppeteerBrowser.on("disconnected", () => {
|
||||
console.log("[PDF] Browser disconnected — will relaunch on next request");
|
||||
_puppeteerBrowser = null;
|
||||
_puppeteerLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[PDF] Puppeteer unavailable:", e.message);
|
||||
_puppeteerBrowser = null;
|
||||
}
|
||||
_puppeteerLoading = false;
|
||||
return _puppeteerBrowser;
|
||||
}
|
||||
|
||||
// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
|
||||
const pdfCache = new Map();
|
||||
const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function pdfCacheGet(key) {
|
||||
const e = pdfCache.get(key);
|
||||
if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
|
||||
return null;
|
||||
}
|
||||
function pdfCacheSet(key, buf) {
|
||||
if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
|
||||
pdfCache.set(key, { buf, ts: Date.now() });
|
||||
}
|
||||
|
||||
async function renderHtmlToPdf(html) {
|
||||
const browser = await getPuppeteerBrowser();
|
||||
if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const buf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
|
||||
});
|
||||
return buf;
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf };
|
||||
661
server/pdf-export/templates.js
Normal file
661
server/pdf-export/templates.js
Normal file
@ -0,0 +1,661 @@
|
||||
// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O
|
||||
|
||||
// FEATURE 2: PDF Export — template generators + puppeteer renderer
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Generate a health score ring SVG (inline, no external deps)
|
||||
* @param {number} score 0–100
|
||||
* @param {string} color CSS color
|
||||
*/
|
||||
function scoreRingSvg(score, color) {
|
||||
const r = 54, cx = 64, cy = 64;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const fill = circ * (1 - score / 100);
|
||||
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
|
||||
return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
|
||||
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
|
||||
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
|
||||
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
|
||||
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
|
||||
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
|
||||
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
|
||||
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Shared CSS for all PDF templates */
|
||||
function pdfBaseCSS() {
|
||||
return `
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
.page { page-break-after: always; min-height: 247mm; padding: 0; }
|
||||
.page:last-child { page-break-after: avoid; }
|
||||
h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
|
||||
h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
|
||||
h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
|
||||
p { margin-bottom: 0.5em; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
|
||||
th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
|
||||
td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
|
||||
tr:nth-child(even) td { background: #f5f3ff; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
|
||||
.badge-green { background: #dcfce7; color: #166534; }
|
||||
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||
.badge-yellow{ background: #fef9c3; color: #854d0e; }
|
||||
.badge-gray { background: #f3f4f6; color: #374151; }
|
||||
.badge-indigo{ background: #e0e7ff; color: #3730a3; }
|
||||
.cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
|
||||
.cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
|
||||
.cover-logo span { color: #1e1b4b; }
|
||||
.cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
|
||||
.cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
|
||||
.cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
|
||||
.cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
|
||||
.cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
|
||||
.metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
|
||||
.metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
|
||||
.metric-card .unit { font-size: 8pt; color: #9ca3af; }
|
||||
.score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
|
||||
.check-list { list-style: none; padding: 0; }
|
||||
.check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
|
||||
.check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
|
||||
.check-pass { color: #16a34a; }
|
||||
.check-fail { color: #dc2626; }
|
||||
.check-warn { color: #d97706; }
|
||||
.section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
|
||||
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PDF HTML for a single ASN.
|
||||
* @param {object} data Full /api/validate response
|
||||
* @param {object} aspa /api/aspa response (may be null)
|
||||
* @param {string} format 'report' | 'executive' | 'technical'
|
||||
*/
|
||||
function buildPdfHtml(data, aspa, format) {
|
||||
const asn = data.asn || '?';
|
||||
const name = data.name || 'Unknown Network';
|
||||
const score = Math.round(data.health_score || 0);
|
||||
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const checks = (data.score_breakdown || []);
|
||||
const rel = data.relationships || {};
|
||||
const counts = rel.counts || {};
|
||||
// validations is an object keyed by check name; rpki details live in rpki_completeness
|
||||
const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
|
||||
const rpkiDetails = (validations.rpki_completeness?.details || []);
|
||||
const totalPfx = data.meta?.total_prefixes || 0;
|
||||
|
||||
// Derived stats — use rpki_completeness for RPKI metrics
|
||||
const rpki = validations.rpki_completeness || {};
|
||||
const rpkiCoverage = rpki.coverage_pct ?? 0;
|
||||
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
|
||||
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
|
||||
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
|
||||
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
|
||||
|
||||
const passedChecks = checks.filter(c => c.status === 'pass').length;
|
||||
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
||||
const aspaDirect = aspa?.direct?.has_aspa ?? null;
|
||||
const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
|
||||
|
||||
// Check list HTML
|
||||
function checkIcon(passed) {
|
||||
if (passed) return '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
|
||||
}
|
||||
|
||||
const checksHtml = checks.map(c => {
|
||||
const isPassed = c.status === 'pass';
|
||||
const isInfo = c.status === 'info';
|
||||
const icon = checkIcon(isPassed || isInfo);
|
||||
const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
|
||||
const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
|
||||
return `
|
||||
<li>
|
||||
${icon}
|
||||
<div>
|
||||
<span style="font-weight:600">${escHtml(c.check || '')}</span>
|
||||
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
|
||||
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
|
||||
// Upstream providers table
|
||||
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
|
||||
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
|
||||
`).join('');
|
||||
|
||||
// Prefix validations table (sample from rpki_completeness.details)
|
||||
const pfxRows = rpkiDetails.slice(0, 20).map(v => {
|
||||
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
||||
return `<tr><td>${escHtml(v.prefix)}</td>
|
||||
<td><span class="badge ${badge}">${v.status}</span></td>
|
||||
<td>${v.validating_roas ?? '-'}</td>
|
||||
<td>—</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
// Recommendations
|
||||
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
|
||||
const recHtml = failedItems.length === 0
|
||||
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
|
||||
: failedItems.map((c, i) => `
|
||||
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
|
||||
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
|
||||
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
// ── Executive pages (always present) ───────────────────────────────────────
|
||||
const coverPage = `
|
||||
<div class="page cover">
|
||||
<div class="cover-stripe"></div>
|
||||
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
||||
<div class="cover-asn">AS${asn}</div>
|
||||
<div class="cover-name">${escHtml(name)}</div>
|
||||
<div class="cover-sub">${formatLabel(format)} Report</div>
|
||||
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
|
||||
${scoreRingSvg(score)}
|
||||
<div>
|
||||
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
|
||||
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
|
||||
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cover-meta">
|
||||
Generated by PeerCortex · ${ts} · peercortex.org
|
||||
| ${totalPfx} prefixes analysed
|
||||
| RPKI coverage ${rpkiCoverage}%
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const summaryPage = `
|
||||
<div class="page">
|
||||
<h2>Executive Summary</h2>
|
||||
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
|
||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
|
||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
|
||||
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
||||
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
|
||||
<div class="metric-card"><div class="label">ASPA Status</div>
|
||||
<div class="value" style="font-size:12pt;margin-top:4px">
|
||||
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
|
||||
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="unit">${aspaProviders} providers</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Status Overview</h3>
|
||||
<ul class="check-list">${checksHtml}</ul>
|
||||
</div>`;
|
||||
|
||||
// ── Recommendations page ────────────────────────────────────────────────────
|
||||
const recsPage = `
|
||||
<div class="page">
|
||||
<h2>Recommendations</h2>
|
||||
<p class="section-intro">Action items to improve network health and security posture.</p>
|
||||
${recHtml}
|
||||
${failedItems.length > 0 ? `
|
||||
<h3 style="margin-top:1.5em">Remediation Priority</h3>
|
||||
<table>
|
||||
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
|
||||
${failedItems.map((c, i) => `
|
||||
<tr>
|
||||
<td>${i+1}</td>
|
||||
<td>${escHtml(c.check || '')}</td>
|
||||
<td><span class="badge badge-red">High</span></td>
|
||||
<td><span class="badge badge-yellow">Medium</span></td>
|
||||
</tr>`).join('')}
|
||||
</table>` : ''}
|
||||
</div>`;
|
||||
|
||||
// ── Detail pages (included for 'report' and 'technical') ───────────────────
|
||||
const rpkiPage = `
|
||||
<div class="page">
|
||||
<h2>RPKI Compliance</h2>
|
||||
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
|
||||
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
|
||||
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
|
||||
</div>
|
||||
<h3>ASPA (Autonomous System Provider Authorization)</h3>
|
||||
<table>
|
||||
<tr><th>Attribute</th><th>Value</th></tr>
|
||||
<tr><td>ASPA Object Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
||||
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
||||
<tr><td>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
||||
</table>
|
||||
${pfxRows ? `
|
||||
<h3>Prefix Validation Sample (first 20)</h3>
|
||||
<table>
|
||||
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
|
||||
${pfxRows}
|
||||
</table>` : ''}
|
||||
</div>`;
|
||||
|
||||
const bgpPage = `
|
||||
<div class="page">
|
||||
<h2>BGP Topology</h2>
|
||||
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
||||
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
|
||||
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
|
||||
</div>
|
||||
${upstreamRows ? `
|
||||
<h3>Top Upstream Providers</h3>
|
||||
<table>
|
||||
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
||||
${upstreamRows}
|
||||
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
||||
</div>`;
|
||||
|
||||
const footer = `
|
||||
<footer>
|
||||
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
|
||||
<span>Generated ${ts} · peercortex.org</span>
|
||||
</footer>`;
|
||||
|
||||
// ── Extra pages for technical / report formats ─────────────────────────────
|
||||
|
||||
// Technical: full prefix table (no row cap) + all upstream rows + check scores
|
||||
const allPfxRows = rpkiDetails.map(v => {
|
||||
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
|
||||
return `<tr><td>${escHtml(v.prefix)}</td>
|
||||
<td><span class="badge ${badge}">${v.status}</span></td>
|
||||
<td>${v.validating_roas ?? '-'}</td>
|
||||
<td>${v.max_length ?? '-'}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
const allUpstreamRows = (rel.upstreams || []).map(u =>
|
||||
`<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>`
|
||||
).join('');
|
||||
|
||||
const checkScoreRows = checks.map(c =>
|
||||
`<tr>
|
||||
<td>${escHtml(c.check || '')}</td>
|
||||
<td><span class="badge ${c.status === 'pass' ? 'badge-green' : c.status === 'info' ? 'badge-indigo' : 'badge-red'}">${c.status?.toUpperCase()}</span></td>
|
||||
<td>${c.earned ?? '-'}</td><td>${c.weight ?? '-'}</td>
|
||||
<td style="font-size:8pt;color:#6b7280">${escHtml(c.detail || '')}</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
|
||||
const technicalHeaderPage = `
|
||||
<div class="page" style="padding-top:2cm">
|
||||
<div style="border-left:4px solid #6366f1;padding-left:16px;margin-bottom:24px">
|
||||
<div style="font-size:8pt;text-transform:uppercase;letter-spacing:1px;color:#6b7280">PeerCortex · Technical Analysis Report</div>
|
||||
<div style="font-size:22pt;font-weight:700;margin:4px 0">AS${asn} — ${escHtml(name)}</div>
|
||||
<div style="font-size:9pt;color:#6b7280">Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%</div>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}/100</div><div class="unit">${passedChecks} pass · ${failedChecks} fail</div></div>
|
||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing</div></div>
|
||||
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
|
||||
<div class="metric-card"><div class="label">Peers / Downstreams</div><div class="value">${counts.peers || 0} / ${counts.downstreams || 0}</div><div class="unit">BGP relationships</div></div>
|
||||
<div class="metric-card"><div class="label">ASPA</div>
|
||||
<div class="value" style="font-size:11pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Deployed' : 'Not deployed'}</span></div>
|
||||
<div class="unit">${aspaProviders} providers registered</div>
|
||||
</div>
|
||||
<div class="metric-card"><div class="label">Prefixes Analysed</div><div class="value">${totalPfx}</div><div class="unit">${rpkiDetails.length} with RPKI data</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const rpkiFullPage = `
|
||||
<div class="page">
|
||||
<h2>RPKI Compliance — Full Prefix Table</h2>
|
||||
<p class="section-intro">${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}</p>
|
||||
<h3>ASPA Object</h3>
|
||||
<table>
|
||||
<tr><th>Attribute</th><th>Value</th></tr>
|
||||
<tr><td>ASPA Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
|
||||
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
|
||||
<tr><td>Registered Providers</td><td style="font-size:8pt">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}</td></tr>
|
||||
</table>
|
||||
${allPfxRows ? `
|
||||
<h3 style="margin-top:1.5em">All Prefixes</h3>
|
||||
<table>
|
||||
<tr><th>Prefix</th><th>RPKI Status</th><th>Validating ROAs</th><th>Max Length</th></tr>
|
||||
${allPfxRows}
|
||||
</table>` : '<p style="color:#9ca3af">No RPKI prefix data available.</p>'}
|
||||
</div>`;
|
||||
|
||||
const bgpFullPage = `
|
||||
<div class="page">
|
||||
<h2>BGP Topology — Full Relationship Table</h2>
|
||||
<p class="section-intro">Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}</p>
|
||||
${allUpstreamRows ? `
|
||||
<h3>All Upstream Providers</h3>
|
||||
<table>
|
||||
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
|
||||
${allUpstreamRows}
|
||||
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
|
||||
${(rel.peers || []).length > 0 ? `
|
||||
<h3 style="margin-top:1.5em">Peer Sample (top 20)</h3>
|
||||
<table>
|
||||
<tr><th>ASN</th><th>Name</th></tr>
|
||||
${(rel.peers || []).slice(0, 20).map(p => `<tr><td>AS${p.asn}</td><td>${escHtml(p.name || '')}</td></tr>`).join('')}
|
||||
</table>` : ''}
|
||||
</div>`;
|
||||
|
||||
const checkDetailPage = `
|
||||
<div class="page">
|
||||
<h2>Check Score Detail</h2>
|
||||
<p class="section-intro">All ${checks.length} health checks with individual scores and diagnostic notes.</p>
|
||||
<table>
|
||||
<tr><th>Check</th><th>Status</th><th>Earned</th><th>Weight</th><th>Detail</th></tr>
|
||||
${checkScoreRows}
|
||||
</table>
|
||||
<div style="margin-top:1.5em;padding:12px;background:#f9fafb;border:1px solid #e5e7eb;font-size:8pt;color:#6b7280">
|
||||
<strong>Scoring:</strong> Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
|
||||
Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Report: add data provenance and ASPA deep-dive
|
||||
const aspaPage = `
|
||||
<div class="page">
|
||||
<h2>ASPA — Provider Authorization Analysis</h2>
|
||||
<p class="section-intro">Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.</p>
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card"><div class="label">ASPA Deployed</div>
|
||||
<div class="value" style="font-size:12pt;margin-top:6px"><span class="badge ${aspaDirect === true ? 'badge-green' : 'badge-red'}">${aspaDirect === true ? 'Yes' : 'No'}</span></div>
|
||||
</div>
|
||||
<div class="metric-card"><div class="label">Providers Listed</div><div class="value">${aspaProviders}</div><div class="unit">in ASPA object</div></div>
|
||||
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">of announced prefixes</div></div>
|
||||
</div>
|
||||
<h3>What ASPA Protects Against</h3>
|
||||
<table>
|
||||
<tr><th>Threat</th><th>ASPA Mitigates?</th><th>Notes</th></tr>
|
||||
<tr><td>Route leaks (valley-free violations)</td><td><span class="badge badge-green">Yes</span></td><td>Providers verify upstream path</td></tr>
|
||||
<tr><td>BGP hijacks (origin AS spoofing)</td><td><span class="badge badge-indigo">Partial</span></td><td>Combined with ROV</td></tr>
|
||||
<tr><td>AS-path prepending attacks</td><td><span class="badge badge-green">Yes</span></td><td>Invalid paths rejected</td></tr>
|
||||
<tr><td>Forged-origin hijacks</td><td><span class="badge badge-red">No</span></td><td>ROA + ROV required</td></tr>
|
||||
</table>
|
||||
${aspaDirect === false ? `
|
||||
<div style="margin-top:1.5em;padding:14px;background:#fef2f2;border-left:3px solid #ef4444;border-radius:4px">
|
||||
<div style="font-weight:600;color:#991b1b;margin-bottom:6px">Recommendation: Deploy ASPA</div>
|
||||
<div style="font-size:9pt;color:#6b7280">
|
||||
1. Identify all upstream transit providers (${counts.upstreams || 0} detected)<br>
|
||||
2. Create an ASPA object in your RIR portal listing their ASNs<br>
|
||||
3. Sign with your RPKI key — takes effect within hours<br>
|
||||
4. Verify with: <code>rpki-client -v</code> or RIPE RPKI Validator
|
||||
</div>
|
||||
</div>` : `
|
||||
<div style="margin-top:1.5em;padding:14px;background:#f0fdf4;border-left:3px solid #16a34a;border-radius:4px">
|
||||
<div style="font-weight:600;color:#15803d">ASPA is deployed — listed providers:</div>
|
||||
<div style="font-size:9pt;color:#374151;margin-top:6px">${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}</div>
|
||||
</div>`}
|
||||
</div>`;
|
||||
|
||||
const provenancePage = `
|
||||
<div class="page">
|
||||
<h2>Data Provenance</h2>
|
||||
<p class="section-intro">All data sources, cache ages, and methodology used to generate this report.</p>
|
||||
<table>
|
||||
<tr><th>Data Source</th><th>What it provides</th><th>Update Frequency</th></tr>
|
||||
<tr><td>Cloudflare RPKI Feed</td><td>ROA + ASPA objects</td><td>Every 4 hours</td></tr>
|
||||
<tr><td>PeeringDB</td><td>IXP presence, peering policy, facilities</td><td>Daily (03:00 UTC)</td></tr>
|
||||
<tr><td>RIPE Stat</td><td>AS relationships, prefix visibility, BGP data</td><td>Real-time / cached 15 min</td></tr>
|
||||
<tr><td>RIPE Atlas</td><td>Active measurement probes by ASN</td><td>Cached 12 hours</td></tr>
|
||||
<tr><td>bgproutes.io</td><td>BGP route visibility fallback</td><td>Real-time</td></tr>
|
||||
<tr><td>bgp.he.net</td><td>AS relationship graph</td><td>Cached 24 hours</td></tr>
|
||||
</table>
|
||||
<h3 style="margin-top:1.5em">Report Metadata</h3>
|
||||
<table>
|
||||
<tr><th>Field</th><th>Value</th></tr>
|
||||
<tr><td>ASN</td><td>AS${asn}</td></tr>
|
||||
<tr><td>Network Name</td><td>${escHtml(name)}</td></tr>
|
||||
<tr><td>Report Format</td><td>${formatLabel(format)}</td></tr>
|
||||
<tr><td>Generated</td><td>${ts}</td></tr>
|
||||
<tr><td>Platform</td><td>PeerCortex · peercortex.org</td></tr>
|
||||
<tr><td>Prefixes Analysed</td><td>${totalPfx}</td></tr>
|
||||
<tr><td>RPKI Objects Checked</td><td>${rpkiDetails.length}</td></tr>
|
||||
<tr><td>Health Checks Run</td><td>${checks.length}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:1.5em;font-size:8pt;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:12px">
|
||||
This report is generated automatically based on publicly available BGP and RPKI data.
|
||||
Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
|
||||
MIT License · github.com/renefichtmueller/PeerCortex
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── Compose pages by format ────────────────────────────────────────────────
|
||||
let bodyContent;
|
||||
if (format === 'executive') {
|
||||
// 3 pages: clean stakeholder summary — no deep technical data
|
||||
bodyContent = coverPage + summaryPage + recsPage;
|
||||
} else if (format === 'technical') {
|
||||
// ~8 pages: engineer-focused, dense, no decorative cover, full data tables
|
||||
bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
|
||||
} else {
|
||||
// 'report' — full stakeholder + technical, ~10 pages
|
||||
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>PeerCortex — AS${asn} ${formatLabel(format)}</title>
|
||||
<style>${pdfBaseCSS()}</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyContent}
|
||||
${footer}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build comparison PDF HTML for two ASNs.
|
||||
*/
|
||||
function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
|
||||
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
function col(d, aspa) {
|
||||
const asn = d.asn || '?';
|
||||
const name = d.name || 'Unknown';
|
||||
const score = Math.round(d.health_score || 0);
|
||||
const rel = d.relationships || {};
|
||||
const counts= rel.counts || {};
|
||||
const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
|
||||
const rpki = vals.rpki_completeness || {};
|
||||
const rpkiV = rpki.with_roa || 0;
|
||||
const rpkiT = rpki.total_checked || 0;
|
||||
const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
|
||||
const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
|
||||
const aspaDep = aspa?.direct?.has_aspa ?? null;
|
||||
const checks= (d.score_breakdown || []);
|
||||
const passC = checks.filter(c => c.status === 'pass').length;
|
||||
const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
|
||||
|
||||
return `
|
||||
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
|
||||
<div style="text-align:center;padding:16px 0 12px;">
|
||||
${scoreRingSvg(score, sc)}
|
||||
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
|
||||
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
|
||||
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
|
||||
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
|
||||
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
|
||||
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
|
||||
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
|
||||
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
|
||||
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
|
||||
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
|
||||
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
|
||||
</table>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
const colHtml = col(d1, aspa1) + col(d2, aspa2);
|
||||
|
||||
// ── Peering Intelligence: common IXPs + common facilities ─────────────────
|
||||
const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
|
||||
const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
|
||||
const ixIds1 = new Set(ixList1.map(x => x.id));
|
||||
const ixIds2 = new Set(ixList2.map(x => x.id));
|
||||
const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
|
||||
const commonIxps = commonIxIds.slice(0, 20).map(id => {
|
||||
const x1 = ixList1.find(x => x.id === id);
|
||||
const x2 = ixList2.find(x => x.id === id);
|
||||
return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
|
||||
});
|
||||
|
||||
const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
|
||||
const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
|
||||
const facIds1 = new Set(facList1.map(f => f.id));
|
||||
const facIds2 = new Set(facList2.map(f => f.id));
|
||||
const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
|
||||
const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
|
||||
|
||||
// IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
|
||||
const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
|
||||
const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
|
||||
|
||||
const commonIxHtml = commonIxps.length > 0
|
||||
? `<table>
|
||||
<tr><th>IXP Name</th><th>City</th><th>AS${d1.asn} Speed</th><th>AS${d2.asn} Speed</th></tr>
|
||||
${commonIxps.map(x => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${escHtml(x.name)}</td>
|
||||
<td>${escHtml(x.city)}</td>
|
||||
<td>${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}</td>
|
||||
<td>${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`
|
||||
: '<p style="color:#6b7280;font-style:italic">No common IXPs found — these networks do not share any Internet Exchange Points.</p>';
|
||||
|
||||
const commonFacHtml = commonFacs.length > 0
|
||||
? `<table>
|
||||
<tr><th>Datacenter / Colocation Facility</th><th>City</th><th>Country</th></tr>
|
||||
${commonFacs.map(f => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${escHtml(f?.name||'')}</td>
|
||||
<td>${escHtml(f?.city||'')}</td>
|
||||
<td>${escHtml(f?.country||'')}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`
|
||||
: '<p style="color:#6b7280;font-style:italic">No shared colocation facilities found.</p>';
|
||||
|
||||
const oppsHtml = (asns, opps) => opps.length === 0
|
||||
? `<p style="color:#16a34a;font-size:9pt">Already maximally peered on these exchanges — or no data available.</p>`
|
||||
: opps.map(x => `<div style="padding:4px 0;border-bottom:1px solid #f3f4f6;font-size:9pt">
|
||||
<span style="font-weight:600">${escHtml(x.name)}</span>
|
||||
${x.city ? `<span style="color:#6b7280"> · ${escHtml(x.city)}</span>` : ''}
|
||||
${x.speed ? `<span class="badge badge-indigo" style="margin-left:6px">${(x.speed/1000).toLocaleString()}G</span>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
|
||||
<div class="page">
|
||||
<h2>Peering Intelligence</h2>
|
||||
<p class="section-intro">Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.</p>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">
|
||||
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonIxps.length}</div>
|
||||
<div style="font-size:9pt;color:#6b7280">Common IXPs</div>
|
||||
</div>
|
||||
<div style="background:#e0e7ff;border-radius:6px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:20pt;font-weight:700;color:#4f46e5">${commonFacs.length}</div>
|
||||
<div style="font-size:9pt;color:#6b7280">Shared Facilities</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Common Internet Exchange Points (direct peering possible)</h3>
|
||||
${commonIxHtml}
|
||||
|
||||
<h3 style="margin-top:1.2em">Shared Colocation Facilities</h3>
|
||||
${commonFacHtml}
|
||||
|
||||
${oppsFor1.length + oppsFor2.length > 0 ? `
|
||||
<h3 style="margin-top:1.2em">Expansion Opportunities</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d2.asn} could join (AS${d1.asn} is there)</div>
|
||||
${oppsHtml(d2.asn, oppsFor2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;color:#4f46e5;margin-bottom:6px;font-size:9.5pt">AS${d1.asn} could join (AS${d2.asn} is there)</div>
|
||||
${oppsHtml(d1.asn, oppsFor1)}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
</div>` : '';
|
||||
|
||||
// Side-by-side check comparison
|
||||
const allChecks = [...new Set([
|
||||
...(d1.score_breakdown || []).map(c => c.check),
|
||||
...(d2.score_breakdown || []).map(c => c.check),
|
||||
])];
|
||||
const checkRows = allChecks.map(checkName => {
|
||||
const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
|
||||
const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
|
||||
const isP1 = c1?.status === 'pass';
|
||||
const isP2 = c2?.status === 'pass';
|
||||
const icon = p => p ? '✓' : '✗';
|
||||
const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
|
||||
return `<tr>
|
||||
<td>${escHtml(checkName)}</td>
|
||||
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
|
||||
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison</title>
|
||||
<style>${pdfBaseCSS()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page cover">
|
||||
<div class="cover-stripe"></div>
|
||||
<div class="cover-logo">⬡ PeerCortex <span>Network Intelligence</span></div>
|
||||
<div style="margin-top:40px">
|
||||
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
|
||||
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
|
||||
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}</div>
|
||||
</div>
|
||||
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
|
||||
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<h2>Head-to-Head Health Checks</h2>
|
||||
<table>
|
||||
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
|
||||
${checkRows}
|
||||
</table>
|
||||
</div>
|
||||
${peeringPage}
|
||||
<footer>
|
||||
<span>PeerCortex · AS${d1.asn} vs AS${d2.asn}</span>
|
||||
<span>Generated ${ts} · peercortex.org</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function formatLabel(fmt) {
|
||||
return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };
|
||||
75
server/resilience-score.js
Normal file
75
server/resilience-score.js
Normal file
@ -0,0 +1,75 @@
|
||||
// Weighted: Transit Diversity 30%, Peering Breadth 25%, IXP Presence 20%, Path Redundancy 25%
|
||||
// Hard cap at 5.0 when single transit provider detected.
|
||||
// Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB.
|
||||
function computeResilienceScore(upstreams, peers, ixConnections, prefixes) {
|
||||
const upstreamCount = upstreams.length;
|
||||
const peerCount = peers.length;
|
||||
const ixCount = [...new Set(ixConnections.map(c => c.ix_id).filter(Boolean))].length;
|
||||
const prefixCount = prefixes.length;
|
||||
|
||||
// Transit Diversity (0-10)
|
||||
let transitRaw = 0;
|
||||
if (upstreamCount === 0) transitRaw = 0;
|
||||
else if (upstreamCount === 1) transitRaw = 2;
|
||||
else if (upstreamCount === 2) transitRaw = 5;
|
||||
else if (upstreamCount <= 4) transitRaw = 7;
|
||||
else transitRaw = 10;
|
||||
|
||||
// Peering Breadth (0-10)
|
||||
let peeringRaw = 0;
|
||||
if (peerCount >= 100) peeringRaw = 10;
|
||||
else if (peerCount >= 50) peeringRaw = 8;
|
||||
else if (peerCount >= 20) peeringRaw = 6;
|
||||
else if (peerCount >= 5) peeringRaw = 4;
|
||||
else if (peerCount >= 1) peeringRaw = 2;
|
||||
|
||||
// IXP Presence (0-10)
|
||||
let ixpRaw = 0;
|
||||
if (ixCount >= 10) ixpRaw = 10;
|
||||
else if (ixCount >= 6) ixpRaw = 8;
|
||||
else if (ixCount >= 3) ixpRaw = 6;
|
||||
else if (ixCount >= 1) ixpRaw = 4;
|
||||
|
||||
// Path Redundancy (0-10) — proxy: prefix diversity + upstream + IXP combination
|
||||
let pathRaw = 0;
|
||||
if (upstreamCount >= 2 && ixCount >= 1) pathRaw = 10;
|
||||
else if (upstreamCount >= 2) pathRaw = 7;
|
||||
else if (ixCount >= 2) pathRaw = 6;
|
||||
else if (upstreamCount === 1) pathRaw = 3;
|
||||
else if (prefixCount > 0) pathRaw = 1;
|
||||
|
||||
const weighted =
|
||||
transitRaw * 0.30 +
|
||||
peeringRaw * 0.25 +
|
||||
ixpRaw * 0.20 +
|
||||
pathRaw * 0.25;
|
||||
|
||||
const singleTransitCap = upstreamCount === 1;
|
||||
let score = Math.round(weighted * 10) / 10;
|
||||
if (singleTransitCap) score = Math.min(score, 5.0);
|
||||
score = Math.max(1.0, Math.min(10.0, score));
|
||||
|
||||
// Only return null if truly no data at all
|
||||
if (upstreamCount === 0 && peerCount === 0 && ixCount === 0 && prefixCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
breakdown: {
|
||||
transit_diversity: { raw: transitRaw, weighted: Math.round(transitRaw * 0.30 * 10) / 10, upstream_count: upstreamCount },
|
||||
peering_breadth: { raw: peeringRaw, weighted: Math.round(peeringRaw * 0.25 * 10) / 10, peer_count: peerCount },
|
||||
ixp_presence: { raw: ixpRaw, weighted: Math.round(ixpRaw * 0.20 * 10) / 10, unique_ixps: ixCount },
|
||||
path_redundancy: { raw: pathRaw, weighted: Math.round(pathRaw * 0.25 * 10) / 10, prefix_count: prefixCount },
|
||||
},
|
||||
single_transit_cap_applied: singleTransitCap,
|
||||
_provenance: {
|
||||
source: "RIPE Stat asn-neighbours + PeeringDB netixlan",
|
||||
validation: "cross-validated",
|
||||
confidence: "high",
|
||||
note: "All inputs independently validated daily against external sources",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeResilienceScore };
|
||||
23
server/resolve-as-names.js
Normal file
23
server/resolve-as-names.js
Normal file
@ -0,0 +1,23 @@
|
||||
const { fetchRipeStatCached } = require("./services/ripe-stat");
|
||||
|
||||
// Batch resolve AS names via RIPE Stat AS overview API
|
||||
async function resolveASNames(providers) {
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < providers.length; i += batchSize) {
|
||||
const batch = providers.slice(i, i + batchSize);
|
||||
const results = await Promise.all(
|
||||
batch.map(p =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
|
||||
.then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
|
||||
.catch(() => ({ asn: p.asn, name: "" }))
|
||||
)
|
||||
);
|
||||
results.forEach(r => {
|
||||
const provider = providers.find(p => p.asn === r.asn);
|
||||
if (provider && r.name) provider.name = r.name;
|
||||
});
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
module.exports = { resolveASNames };
|
||||
54
server/route-leak.js
Normal file
54
server/route-leak.js
Normal file
@ -0,0 +1,54 @@
|
||||
const { TIER1_ASNS } = require("./data/tier1-asns");
|
||||
|
||||
// Heuristic: detects suspicious routing relationships using RIPE Stat neighbour data.
|
||||
// NOT real-time. False positives possible for large networks with many Tier-1 relationships.
|
||||
// Confidence: MEDIUM — pattern-based, not path-level analysis.
|
||||
function computeRouteLeakDetection(upstreams, downstreams, peers) {
|
||||
const upstreamAsns = new Set(upstreams.map(n => n.asn));
|
||||
const downstreamAsns = new Set(downstreams.map(n => n.asn));
|
||||
|
||||
const tier1Upstreams = upstreams.filter(n => TIER1_ASNS.has(n.asn));
|
||||
const tier1Downstreams = downstreams.filter(n => TIER1_ASNS.has(n.asn));
|
||||
|
||||
const patterns = [];
|
||||
|
||||
// Pattern A: Tier-1 appearing as BOTH upstream AND downstream → sandwich candidate
|
||||
const sandwich = tier1Upstreams.filter(n => downstreamAsns.has(n.asn));
|
||||
sandwich.forEach(n => {
|
||||
patterns.push({
|
||||
type: "sandwich_candidate",
|
||||
asn: n.asn,
|
||||
name: n.name,
|
||||
description: `AS${n.asn} (${n.name}) appears as both upstream and downstream — possible route leak vector`,
|
||||
});
|
||||
});
|
||||
|
||||
// Pattern B: Tier-1 as downstream (re-originating routes to Tier-1s)
|
||||
tier1Downstreams.forEach(n => {
|
||||
if (!upstreamAsns.has(n.asn)) {
|
||||
patterns.push({
|
||||
type: "tier1_downstream",
|
||||
asn: n.asn,
|
||||
name: n.name,
|
||||
description: `AS${n.asn} (${n.name}) is a downstream — unusual for a Tier-1, may indicate leaked routes`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const detected = patterns.length > 0;
|
||||
|
||||
return {
|
||||
detected,
|
||||
patterns,
|
||||
tier1_upstream_count: tier1Upstreams.length,
|
||||
tier1_downstream_count: tier1Downstreams.length,
|
||||
_provenance: {
|
||||
source: "RIPE Stat asn-neighbours",
|
||||
validation: "heuristic",
|
||||
confidence: "medium",
|
||||
note: "Pattern-based detection only. Not real-time (15-min RIPE RIS snapshot). False positives possible for large networks with legitimate Tier-1 relationships.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeRouteLeakDetection };
|
||||
114
server/routes/aspa-adoption.js
Normal file
114
server/routes/aspa-adoption.js
Normal file
@ -0,0 +1,114 @@
|
||||
const { rpkiAspaMap } = require("../services/rpki");
|
||||
const { fetchIpv6AdoptionStats } = require("../ipv6-adoption");
|
||||
const {
|
||||
aspaAdoptionDailyHistory, getAdoptionTrend, buildAspaAdoptionDashboard,
|
||||
} = require("../aspa-adoption/tracker");
|
||||
|
||||
// GET /aspa-adoption → dashboard HTML
|
||||
function handleDashboard(req, res) {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.writeHead(200);
|
||||
return res.end(buildAspaAdoptionDashboard());
|
||||
}
|
||||
|
||||
// Linear regression forecast -- duplicated near-identically in
|
||||
// buildAspaAdoptionDashboard (server/aspa-adoption/tracker.js) since both
|
||||
// use the same formula over possibly-different trend windows; kept as two
|
||||
// call sites rather than merged to avoid changing either's behavior here.
|
||||
function forecast(data, daysAhead) {
|
||||
if (data.length < 2) return null;
|
||||
const n = data.length;
|
||||
const xs = data.map((_, i) => i);
|
||||
const ys = data.map(d => d.coverage_pct_atlas);
|
||||
const xMean = xs.reduce((a,b)=>a+b,0)/n;
|
||||
const yMean = ys.reduce((a,b)=>a+b,0)/n;
|
||||
const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
|
||||
const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
|
||||
if (den===0) return yMean;
|
||||
const slope = num/den;
|
||||
return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
|
||||
}
|
||||
|
||||
// GET /api/aspa-adoption-stats?period=7d|30d|1y
|
||||
function handleStats(req, res, url) {
|
||||
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
||||
? url.searchParams.get('period') : '30d';
|
||||
const trend = getAdoptionTrend(period);
|
||||
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
|
||||
|
||||
const trend30 = getAdoptionTrend('30d');
|
||||
const result = {
|
||||
period,
|
||||
current: {
|
||||
date: latest?.date,
|
||||
aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
|
||||
roa_count: latest?.roa_count,
|
||||
atlas_asns_total: latest?.atlas_asns_total ?? 0,
|
||||
atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
|
||||
coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
|
||||
delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
|
||||
},
|
||||
trend: trend.map(s => ({
|
||||
date: s.date,
|
||||
aspa_objects: s.aspa_objects,
|
||||
coverage_pct_atlas: s.coverage_pct_atlas,
|
||||
atlas_asns_total: s.atlas_asns_total,
|
||||
})),
|
||||
forecast: {
|
||||
predicted_90d: forecast(trend30, 90),
|
||||
confidence: trend30.length >= 10 ? 0.75 : 0.5,
|
||||
method: 'linear_regression',
|
||||
based_on_samples: trend30.length,
|
||||
},
|
||||
meta: {
|
||||
total_snapshots: aspaAdoptionDailyHistory.length,
|
||||
last_updated: latest?.date ?? null,
|
||||
denominator: 'atlas_visible_asns',
|
||||
source: 'rpki_cloudflare_feed',
|
||||
}
|
||||
};
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
|
||||
function handleStatsExport(req, res, url) {
|
||||
const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
|
||||
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
||||
? url.searchParams.get('period') : '30d';
|
||||
const data = getAdoptionTrend(period);
|
||||
|
||||
if (fmt === 'csv') {
|
||||
const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
|
||||
const rows = data.map(s =>
|
||||
`${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
|
||||
).join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
|
||||
res.writeHead(200);
|
||||
return res.end(header + rows);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
|
||||
}
|
||||
|
||||
// GET /api/ipv6-adoption-stats?refresh=1
|
||||
async function handleIpv6Stats(req, res, url) {
|
||||
const force = url.searchParams.get('refresh') === '1';
|
||||
try {
|
||||
const data = await fetchIpv6AdoptionStats(force);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleDashboard, handleStats, handleStatsExport, handleIpv6Stats };
|
||||
133
server/routes/aspa-legacy.js
Normal file
133
server/routes/aspa-legacy.js
Normal file
@ -0,0 +1,133 @@
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { resolveASNames } = require("../resolve-as-names");
|
||||
const { ensureAspaCache, lookupAspaFromRpki } = require("../services/rpki");
|
||||
const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
|
||||
async function handleAspaLegacy(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
||||
}
|
||||
const cachedAspa = resultCacheGet(aspaResultCache, rawAsn);
|
||||
if (cachedAspa !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(cachedAspa));
|
||||
}
|
||||
const start = Date.now();
|
||||
let _aspaDone = false;
|
||||
const _aspaTimer = setTimeout(() => {
|
||||
if (!_aspaDone) {
|
||||
_aspaDone = true;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
|
||||
}
|
||||
}, 12000);
|
||||
try {
|
||||
const [lgData, neighbourData] = await Promise.all([
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn, { timeout: 3000 }).catch(() => null),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 4000 }),
|
||||
]);
|
||||
|
||||
const rrcs = lgData?.data?.rrcs || [];
|
||||
const asPaths = [];
|
||||
|
||||
rrcs.forEach((rrc) => {
|
||||
const peers = rrc.peers || [];
|
||||
peers.forEach((peer) => {
|
||||
const path = peer.as_path || "";
|
||||
const pathArr = path.split(" ").map(Number).filter(Boolean);
|
||||
if (pathArr.length > 1) {
|
||||
asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
|
||||
const neighbours = neighbourData?.data?.neighbours || [];
|
||||
const leftNeighbours = neighbours.filter((n) => n.type === "left");
|
||||
const upstreamSet = new Set();
|
||||
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
|
||||
|
||||
// Classify left neighbours: high-power = likely upstream, low-power = likely peer
|
||||
const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
|
||||
const detectedProviders = [...upstreamSet].map((asn) => {
|
||||
const nb = leftNeighbours.find((n) => n.asn === asn);
|
||||
const power = nb ? (nb.power || 0) : 0;
|
||||
const powerPct = Math.round((power / maxPower) * 100);
|
||||
const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
|
||||
return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
|
||||
});
|
||||
|
||||
await resolveASNames(detectedProviders);
|
||||
|
||||
// Check Cloudflare RPKI feed for ASPA object
|
||||
await ensureAspaCache();
|
||||
const aspaLookup = lookupAspaFromRpki(rawAsn);
|
||||
const aspaObjectExists = aspaLookup.exists;
|
||||
const aspaDeclaredProviders = aspaLookup.providers;
|
||||
|
||||
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
|
||||
let recommendedAspa =
|
||||
"aut-num: AS" + rawAsn + "\n" +
|
||||
"# Recommended ASPA object:\n" +
|
||||
"# customer: AS" + rawAsn + "\n" +
|
||||
"# provider-set: " + providerList + "\n" +
|
||||
"# AFI: ipv4, ipv6\n" +
|
||||
"#\n" +
|
||||
"# Detected providers from BGP path analysis:\n" +
|
||||
detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
|
||||
|
||||
// If ASPA object exists, show RPKI-declared providers
|
||||
if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
|
||||
recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
|
||||
aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
|
||||
}
|
||||
|
||||
const samplePaths = asPaths.slice(0, 10).map((p) => {
|
||||
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
|
||||
const idx = p.path.indexOf(parseInt(rawAsn));
|
||||
const provider = idx > 0 ? p.path[idx - 1] : null;
|
||||
return {
|
||||
rrc: p.rrc,
|
||||
prefix: p.prefix,
|
||||
path: pathStr,
|
||||
detected_provider: provider ? "AS" + provider : null,
|
||||
provider_in_set: provider ? upstreamSet.has(provider) : false,
|
||||
};
|
||||
});
|
||||
|
||||
if (_aspaDone) return; // hard timeout already responded
|
||||
_aspaDone = true;
|
||||
clearTimeout(_aspaTimer);
|
||||
const duration = Date.now() - start;
|
||||
const aspaResult = {
|
||||
meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
asn: parseInt(rawAsn),
|
||||
detected_providers: detectedProviders,
|
||||
provider_count: detectedProviders.length,
|
||||
aspa_object_exists: aspaObjectExists,
|
||||
aspa_feed_healthy: aspaLookup.feedLoaded,
|
||||
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
||||
aspa_declared_count: aspaDeclaredProviders.length,
|
||||
recommended_aspa: recommendedAspa,
|
||||
path_analysis: {
|
||||
total_paths_seen: asPaths.length,
|
||||
sample_paths: samplePaths,
|
||||
},
|
||||
};
|
||||
resultCacheSet(aspaResultCache, rawAsn, aspaResult);
|
||||
return res.end(JSON.stringify(aspaResult, null, 2));
|
||||
} catch (err) {
|
||||
if (!_aspaDone) {
|
||||
_aspaDone = true;
|
||||
clearTimeout(_aspaTimer);
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleAspaLegacy };
|
||||
271
server/routes/aspa-verify.js
Normal file
271
server/routes/aspa-verify.js
Normal file
@ -0,0 +1,271 @@
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { hasAsSet, verifyUpstream, verifyDownstream, detectValleys, collapsePrepends, calculateAspaReadinessScore } = require("../aspa-verification/engine");
|
||||
const { resolveASNames } = require("../resolve-as-names");
|
||||
const { ensureAspaCache, lookupAspaFromRpki, rpkiAspaMap, validateRPKIWithCache } = require("../services/rpki");
|
||||
const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
|
||||
async function handleAspaVerify(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
||||
}
|
||||
const cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn);
|
||||
if (cachedVerify !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(cachedVerify));
|
||||
}
|
||||
const targetAsn = parseInt(rawAsn);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Fetch neighbour and prefix data first
|
||||
const [neighbourData, prefixData] = await Promise.all([
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
|
||||
]);
|
||||
|
||||
// Use looking-glass with actual prefixes to get BGP paths
|
||||
const announcedPrefixes = prefixData?.data?.prefixes || [];
|
||||
const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3
|
||||
|
||||
// Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each)
|
||||
const lgResults = await Promise.all(
|
||||
samplePrefixes.map((pfx) =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null)
|
||||
)
|
||||
);
|
||||
|
||||
// Extract AS paths from looking glass results
|
||||
const allPaths = [];
|
||||
const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths
|
||||
|
||||
lgResults.forEach((lgData) => {
|
||||
const rrcs = lgData?.data?.rrcs || [];
|
||||
rrcs.forEach((rrc) => {
|
||||
const peers = rrc.peers || [];
|
||||
peers.forEach((peer) => {
|
||||
const rawPath = peer.as_path || "";
|
||||
const pathArr = rawPath.split(" ").map(Number).filter(Boolean);
|
||||
if (pathArr.length > 1) {
|
||||
allPaths.push({
|
||||
rrc: rrc.rrc,
|
||||
path: pathArr,
|
||||
rawPath: rawPath,
|
||||
prefix: peer.prefix || "",
|
||||
hasAsSet: hasAsSet(rawPath),
|
||||
});
|
||||
const idx = pathArr.indexOf(targetAsn);
|
||||
if (idx > 0) {
|
||||
const neighbour = pathArr[idx - 1];
|
||||
pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
|
||||
// AS-path analysis is used for frequency/confirmation, NOT as standalone provider source
|
||||
const neighbours = neighbourData?.data?.neighbours || [];
|
||||
const leftNeighbours = neighbours.filter((n) => n.type === "left");
|
||||
const upstreamSet = new Set();
|
||||
leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
|
||||
|
||||
// Classify left neighbours: high-power = likely upstream, low-power = likely peer
|
||||
const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
|
||||
const detectedProviders = [...upstreamSet].map((asn) => {
|
||||
const nb = leftNeighbours.find((n) => n.asn === asn);
|
||||
const power = nb ? (nb.power || 0) : 0;
|
||||
const powerPct = Math.round((power / maxPower) * 100);
|
||||
const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
|
||||
return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
|
||||
});
|
||||
|
||||
await resolveASNames(detectedProviders);
|
||||
|
||||
// Count how often each provider appears in paths
|
||||
const providerFrequency = new Map();
|
||||
allPaths.forEach((p) => {
|
||||
const idx = p.path.indexOf(targetAsn);
|
||||
if (idx > 0) {
|
||||
const prov = p.path[idx - 1];
|
||||
providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Check Cloudflare RPKI feed for ASPA object
|
||||
await ensureAspaCache();
|
||||
const aspaLookup = lookupAspaFromRpki(targetAsn);
|
||||
const aspaObjectExists = aspaLookup.exists;
|
||||
const aspaDeclaredProviders = aspaLookup.providers;
|
||||
|
||||
// Build ASPA store from RPKI feed data (real ASPA objects only). If the
|
||||
// target has no real ASPA object, its hops must show "NoAttestation" --
|
||||
// NOT a fabricated declaration built from heuristically-detected BGP
|
||||
// neighbours, which would contradict the aspa_object_exists:false this
|
||||
// same response reports and falsely present unattested hops as
|
||||
// "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for
|
||||
// any ASN with no entry in aspaStore, so simply not setting one here is
|
||||
// correct and sufficient -- no fallback needed.
|
||||
const aspaStore = new Map();
|
||||
if (aspaObjectExists) {
|
||||
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
|
||||
}
|
||||
// Also populate store with all known ASPA objects from the RPKI feed
|
||||
// for providers that have their own ASPA objects (enables full path verification)
|
||||
for (const [cas, provSet] of rpkiAspaMap) {
|
||||
if (!aspaStore.has(cas)) {
|
||||
aspaStore.set(cas, provSet);
|
||||
}
|
||||
}
|
||||
// Deliberately NOT adding empty Set() entries for detected-but-unattested
|
||||
// providers here: aspaStore.get() returning undefined for them makes
|
||||
// hopCheck() correctly report "NoAttestation". An empty (but present) Set
|
||||
// would make providers.has(x) return false, misreporting "NotProviderPlus"
|
||||
// (implying a real ASPA object that denies this hop) instead of "we simply
|
||||
// don't know" -- see the 2026-07-16 audit for how this previously caused
|
||||
// clean, unattested paths to be misclassified as route leaks.
|
||||
|
||||
// Sample paths for verification (up to 50)
|
||||
const samplePaths = allPaths.slice(0, 50);
|
||||
const pathResults = samplePaths.map((p) => {
|
||||
const upstream = verifyUpstream(p.path, aspaStore, p.rawPath);
|
||||
const downstream = verifyDownstream(p.path, aspaStore, p.rawPath);
|
||||
const valleys = detectValleys(p.path, aspaStore);
|
||||
|
||||
return {
|
||||
rrc: p.rrc,
|
||||
prefix: p.prefix,
|
||||
path: p.path.map((a) => "AS" + a).join(" "),
|
||||
collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "),
|
||||
has_as_set: p.hasAsSet,
|
||||
upstream_verification: upstream,
|
||||
downstream_verification: downstream,
|
||||
valleys: valleys,
|
||||
overall: p.hasAsSet
|
||||
? "Invalid"
|
||||
: upstream.result === "Valid" && downstream.result === "Valid"
|
||||
? "Valid"
|
||||
: upstream.result === "Invalid" || downstream.result === "Invalid"
|
||||
? "Invalid"
|
||||
: "Unknown",
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
const validPaths = pathResults.filter((p) => p.overall === "Valid").length;
|
||||
const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length;
|
||||
const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length;
|
||||
const asSetPaths = pathResults.filter((p) => p.has_as_set).length;
|
||||
const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length;
|
||||
|
||||
// For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal),
|
||||
// only Invalid actually indicates problems
|
||||
const pathNotInvalidPct = pathResults.length > 0
|
||||
? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100)
|
||||
: 0;
|
||||
const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0;
|
||||
|
||||
// Provider audit: compare detected vs declared
|
||||
const detectedSet = new Set(detectedProviders.map((p) => p.asn));
|
||||
const declaredSet = new Set(aspaDeclaredProviders);
|
||||
|
||||
const missingFromAspa = detectedProviders
|
||||
.filter((p) => !declaredSet.has(p.asn))
|
||||
.map((p) => ({
|
||||
asn: p.asn,
|
||||
name: p.name,
|
||||
frequency: providerFrequency.get(p.asn) || 0,
|
||||
frequency_pct: allPaths.length > 0
|
||||
? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.frequency - a.frequency);
|
||||
|
||||
const extraInAspa = aspaDeclaredProviders
|
||||
.filter((asn) => !detectedSet.has(asn))
|
||||
.map((asn) => ({
|
||||
asn,
|
||||
name: "",
|
||||
seen_in_paths: false,
|
||||
}));
|
||||
|
||||
const providerCompleteness = detectedProviders.length > 0
|
||||
? Math.round(
|
||||
(detectedProviders.filter((p) => declaredSet.has(p.asn)).length /
|
||||
detectedProviders.length) *
|
||||
100
|
||||
)
|
||||
: aspaObjectExists ? 100 : 0;
|
||||
|
||||
// Get RPKI coverage for readiness score
|
||||
// Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs)
|
||||
await ensureAspaCache();
|
||||
const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
|
||||
const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
|
||||
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
||||
// must not lower a network's displayed ASPA readiness score.
|
||||
const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable");
|
||||
const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length;
|
||||
const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0;
|
||||
|
||||
// Calculate readiness score
|
||||
const readinessScore = calculateAspaReadinessScore({
|
||||
rpkiCoverage,
|
||||
aspaObjectExists,
|
||||
providerCompleteness,
|
||||
pathValidationPct: pathNotInvalidPct,
|
||||
});
|
||||
|
||||
const duration = Date.now() - start;
|
||||
const verifyResult = {
|
||||
meta: {
|
||||
query: "AS" + rawAsn,
|
||||
duration_ms: duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
paths_analyzed: pathResults.length,
|
||||
total_paths_seen: allPaths.length,
|
||||
},
|
||||
asn: targetAsn,
|
||||
readiness_score: readinessScore,
|
||||
aspa_object_exists: aspaObjectExists,
|
||||
aspa_feed_healthy: aspaLookup.feedLoaded,
|
||||
detected_providers: detectedProviders.map((p) => ({
|
||||
...p,
|
||||
frequency: providerFrequency.get(p.asn) || 0,
|
||||
frequency_pct: allPaths.length > 0
|
||||
? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
|
||||
: 0,
|
||||
})),
|
||||
provider_audit: {
|
||||
declared_count: aspaDeclaredProviders.length,
|
||||
detected_count: detectedProviders.length,
|
||||
completeness_pct: providerCompleteness,
|
||||
missing_from_aspa: missingFromAspa,
|
||||
extra_in_aspa: extraInAspa,
|
||||
},
|
||||
path_verification: {
|
||||
total: pathResults.length,
|
||||
valid: validPaths,
|
||||
invalid: invalidPaths,
|
||||
unknown: unknownPaths,
|
||||
as_set_flagged: asSetPaths,
|
||||
valley_detected: valleyPaths,
|
||||
valid_pct: pathValidPct,
|
||||
not_invalid_pct: pathNotInvalidPct,
|
||||
results: pathResults,
|
||||
},
|
||||
rpki_coverage: rpkiCoverage,
|
||||
};
|
||||
resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult);
|
||||
return res.end(JSON.stringify(verifyResult, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleAspaVerify };
|
||||
108
server/routes/bgp.js
Normal file
108
server/routes/bgp.js
Normal file
@ -0,0 +1,108 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { bgproutesResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
|
||||
// Queries local PostgreSQL bgp_routes table — zero external API calls
|
||||
async function handleBgp(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
const prefix = url.searchParams.get("prefix") || "";
|
||||
if (!rawAsn && !prefix) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
|
||||
}
|
||||
const cacheKey = rawAsn || prefix;
|
||||
const cached = resultCacheGet(bgproutesResultCache, cacheKey);
|
||||
if (cached !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = {
|
||||
meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
|
||||
bgp_status: null,
|
||||
threat_intel: null,
|
||||
};
|
||||
|
||||
// ---- BGP Status (local DB lookup) ----
|
||||
if (prefix) {
|
||||
// Prefix lookup: Get BGP status for this prefix
|
||||
const bgpStatus = await localDb.getBgpStatus(prefix);
|
||||
if (bgpStatus) {
|
||||
result.bgp_status = {
|
||||
prefix,
|
||||
announced: bgpStatus.announced,
|
||||
origin_asns: bgpStatus.origin_asns,
|
||||
visibility_percent: bgpStatus.visibility_percent,
|
||||
last_seen: bgpStatus.last_seen,
|
||||
source: "local_bgp",
|
||||
};
|
||||
// Check for hijack (multiple origin ASNs). null means the check itself
|
||||
// failed (DB error) -- distinct from a genuine single/no-origin result.
|
||||
const hijackAsns = await localDb.checkBgpHijack(prefix);
|
||||
if (hijackAsns === null) {
|
||||
result.bgp_status.hijack_check_unavailable = true;
|
||||
} else if (hijackAsns.length > 1) {
|
||||
result.bgp_status.hijack_warning = {
|
||||
detected: true,
|
||||
origin_asns: hijackAsns,
|
||||
message: `Multiple origin ASNs detected for ${prefix}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (rawAsn) {
|
||||
// ASN lookup: Get all announced prefixes for this ASN
|
||||
const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
|
||||
if (prefixes && prefixes.length > 0) {
|
||||
result.bgp_status = {
|
||||
asn: rawAsn,
|
||||
announced_count: prefixes.length,
|
||||
prefixes: prefixes.slice(0, 50).map((p) => ({
|
||||
prefix: p.prefix,
|
||||
origin_asn: p.origin_asn,
|
||||
visibility_percent: p.visibility_percent,
|
||||
last_seen: p.last_seen,
|
||||
})),
|
||||
source: "local_bgp",
|
||||
};
|
||||
} else {
|
||||
result.bgp_status = {
|
||||
asn: rawAsn,
|
||||
announced: false,
|
||||
announced_count: 0,
|
||||
message: "No prefixes found for this ASN in local BGP table",
|
||||
source: "local_bgp",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Threat Intelligence (local cache lookup) ----
|
||||
// If we have an IP context, look up threat intel
|
||||
if (prefix && prefix.includes(".")) {
|
||||
// Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
|
||||
const ipAddr = prefix.split("/")[0];
|
||||
const threat = await localDb.getThreatIntel(ipAddr);
|
||||
if (threat) {
|
||||
result.threat_intel = {
|
||||
ip_address: threat.ip_address,
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
cached_at: threat.cached_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
result.meta.duration_ms = Date.now() - start;
|
||||
resultCacheSet(bgproutesResultCache, cacheKey, result);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
console.error("[/api/bgp] Error:", err.message);
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleBgp };
|
||||
46
server/routes/changelog.js
Normal file
46
server/routes/changelog.js
Normal file
@ -0,0 +1,46 @@
|
||||
const fs = require("fs");
|
||||
|
||||
// Changelog page: renders CHANGELOG.md as HTML
|
||||
function handleChangelog(req, res) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleChangelog };
|
||||
175
server/routes/compare.js
Normal file
175
server/routes/compare.js
Normal file
@ -0,0 +1,175 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
const { validateRPKIWithCache } = require("../services/rpki");
|
||||
const { cacheGet, cacheSet, CACHE_TTL_DEFAULT } = require("../caches/response-caches");
|
||||
|
||||
// Compare endpoint: /api/compare?asn1=X&asn2=Y
|
||||
async function handleCompare(req, res, url) {
|
||||
const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
|
||||
const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
|
||||
if (!asn1 || !asn2) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
|
||||
}
|
||||
|
||||
const compareCacheKey = "compare:" + asn1 + ":" + asn2;
|
||||
const compareCached = cacheGet(compareCacheKey);
|
||||
if (compareCached) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(compareCached));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
// ALL calls in parallel — single batch
|
||||
// Phase 1: Get PDB net objects + RIPE data
|
||||
const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
|
||||
fetchPeeringDB("/net?asn=" + asn1),
|
||||
fetchPeeringDB("/net?asn=" + asn2),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
|
||||
]);
|
||||
|
||||
const net1 = pdb1?.data?.[0] || {};
|
||||
const net2 = pdb2?.data?.[0] || {};
|
||||
const netId1 = net1.id;
|
||||
const netId2 = net2.id;
|
||||
|
||||
// Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
|
||||
const ixFacPromises = [];
|
||||
ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
|
||||
ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
|
||||
ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
|
||||
ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
|
||||
const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
|
||||
|
||||
const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
|
||||
const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
|
||||
const ix1Names = {};
|
||||
(ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
|
||||
const ix2Names = {};
|
||||
(ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
|
||||
|
||||
const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
|
||||
const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
|
||||
const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
|
||||
|
||||
const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
|
||||
const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
|
||||
const fac1Names = {};
|
||||
(fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
|
||||
const fac2Names = {};
|
||||
(fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
|
||||
|
||||
const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
|
||||
|
||||
const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
||||
const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
|
||||
const up1Set = new Set(nb1.map((n) => n.asn));
|
||||
const up2Set = new Set(nb2.map((n) => n.asn));
|
||||
const nb1Map = {};
|
||||
nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
|
||||
const nb2Map = {};
|
||||
nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
|
||||
|
||||
const commonUpstreams = [...up1Set]
|
||||
.filter((a) => up2Set.has(a))
|
||||
.map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
|
||||
|
||||
// ---- Threat Intelligence Enrichment for Common Upstreams ----
|
||||
const threatMap = {};
|
||||
const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
|
||||
try {
|
||||
const asNum = String(n.asn);
|
||||
const threat = await localDb.getThreatIntel(asNum);
|
||||
if (threat) {
|
||||
threatMap[n.asn] = {
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
||||
}
|
||||
});
|
||||
await Promise.race([
|
||||
Promise.all(threatPromises),
|
||||
new Promise(r => setTimeout(r, 2000)),
|
||||
]);
|
||||
|
||||
// Attach threat status to upstream objects
|
||||
commonUpstreams.forEach((n) => {
|
||||
if (threatMap[n.asn]) {
|
||||
n.threat_level = threatMap[n.asn].threat_level;
|
||||
n.threat_confidence = threatMap[n.asn].confidence_score;
|
||||
n.threat_source = threatMap[n.asn].source;
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
|
||||
const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
||||
const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
|
||||
const [, rpki1Results, rpki2Results] = await Promise.race([
|
||||
Promise.all([
|
||||
commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
||||
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
||||
.catch(() => {})
|
||||
)) : Promise.resolve([]),
|
||||
Promise.all(pfx1.map((p) => validateRPKIWithCache(asn1, p))),
|
||||
Promise.all(pfx2.map((p) => validateRPKIWithCache(asn2, p))),
|
||||
]),
|
||||
new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
|
||||
]);
|
||||
|
||||
const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
|
||||
const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
|
||||
const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
|
||||
const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
|
||||
|
||||
const duration = Date.now() - start;
|
||||
const compareResult = {
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
asn1: {
|
||||
asn: parseInt(asn1),
|
||||
name: net1.name || "Unknown",
|
||||
ix_count: ix1Set.size,
|
||||
fac_count: fac1Set.size,
|
||||
upstream_count: up1Set.size,
|
||||
rpki_coverage: rpki1Pct,
|
||||
},
|
||||
asn2: {
|
||||
asn: parseInt(asn2),
|
||||
name: net2.name || "Unknown",
|
||||
ix_count: ix2Set.size,
|
||||
fac_count: fac2Set.size,
|
||||
upstream_count: up2Set.size,
|
||||
rpki_coverage: rpki2Pct,
|
||||
},
|
||||
common_ixps: commonIX,
|
||||
only_asn1_ixps: only1IX,
|
||||
only_asn2_ixps: only2IX,
|
||||
common_facilities: commonFac,
|
||||
common_upstreams: commonUpstreams,
|
||||
rpki_comparison: {
|
||||
asn1_coverage: rpki1Pct,
|
||||
asn2_coverage: rpki2Pct,
|
||||
asn1_checked: rpki1Results.length,
|
||||
asn2_checked: rpki2Results.length,
|
||||
better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
|
||||
},
|
||||
};
|
||||
cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
|
||||
res.end(JSON.stringify(compareResult, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
|
||||
}
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleCompare };
|
||||
125
server/routes/enrich.js
Normal file
125
server/routes/enrich.js
Normal file
@ -0,0 +1,125 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const { fetchJSON } = require("../services/http-helpers");
|
||||
|
||||
// Feature 28b: Company enrichment via Wikipedia + website meta scraping
|
||||
async function handleEnrich(req, res, url) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
const companyName = (url.searchParams.get("name") || "").trim();
|
||||
const website = (url.searchParams.get("website") || "").trim();
|
||||
const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";
|
||||
|
||||
let description = null;
|
||||
let wikiUrl = null;
|
||||
|
||||
try {
|
||||
// 1. Direct Wikipedia lookup by company name
|
||||
if (companyName) {
|
||||
const nameLower = companyName.toLowerCase();
|
||||
const isRelevant = (title, extract) => {
|
||||
const titleLower = (title || "").toLowerCase();
|
||||
const extractLower = (extract || "").toLowerCase();
|
||||
const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
|
||||
const titleMatch = nameWords.some(w => titleLower.includes(w));
|
||||
const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
|
||||
const hasNetContext = netTerms.some(t => extractLower.includes(t));
|
||||
return titleMatch && (hasNetContext || titleMatch);
|
||||
};
|
||||
|
||||
// Direct title lookup — try full name first, then first word as fallback
|
||||
const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
|
||||
const firstName = cleanName.split(/\s+/)[0];
|
||||
const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
|
||||
for (const tryName of namesToTry) {
|
||||
const wikiDirect = await fetchJSON(
|
||||
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
|
||||
if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
|
||||
description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
|
||||
wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wikipedia search if direct lookup didn't match
|
||||
if (!description) {
|
||||
const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
|
||||
const searchData = await fetchJSON(
|
||||
"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
|
||||
const topTitle = searchData[1][0];
|
||||
const wikiSearch = await fetchJSON(
|
||||
"https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
|
||||
if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
|
||||
description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
|
||||
wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: scrape website meta description (follows up to 3 redirects)
|
||||
function fetchPage(pageUrl, hops) {
|
||||
if (hops <= 0) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
const mod = pageUrl.startsWith("https") ? https : http;
|
||||
const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
res.resume(); // drain to free socket
|
||||
const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
|
||||
return resolve(fetchPage(next, hops - 1));
|
||||
}
|
||||
let data = "";
|
||||
res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
|
||||
res.on("end", () => resolve(data));
|
||||
});
|
||||
req.on("error", () => resolve(null));
|
||||
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||
});
|
||||
}
|
||||
if (!description && website) {
|
||||
let wsUrl = website;
|
||||
if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
|
||||
const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
|
||||
const tryUrls = [aboutUrl, wsUrl];
|
||||
for (const tryUrl of tryUrls) {
|
||||
const resp = await fetchPage(tryUrl, 3);
|
||||
if (!resp) continue;
|
||||
const metaPatterns = [
|
||||
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
|
||||
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
|
||||
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
|
||||
/<meta[^>]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
|
||||
];
|
||||
let matched = false;
|
||||
for (const pat of metaPatterns) {
|
||||
const m = resp.match(pat);
|
||||
if (m && m[1]) {
|
||||
description = m[1].replace(/ |✓|&/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Return whatever we have
|
||||
}
|
||||
|
||||
return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleEnrich };
|
||||
70
server/routes/feedback.js
Normal file
70
server/routes/feedback.js
Normal file
@ -0,0 +1,70 @@
|
||||
const fs = require("fs");
|
||||
const { sendFeedbackMail } = require("../../src/backend/services/smtp");
|
||||
|
||||
const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
|
||||
const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
|
||||
|
||||
function handleOptions(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// POST /api/feedback — submit feedback entry
|
||||
function handlePost(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
let body = '';
|
||||
req.on('data', function(chunk) { body += chunk; });
|
||||
req.on('end', function() {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (!data.message || String(data.message).trim().length < 3) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ ok: false, error: 'Message too short' }));
|
||||
}
|
||||
const entry = {
|
||||
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
timestamp: new Date().toISOString(),
|
||||
category: String(data.category || 'General').slice(0, 50),
|
||||
message: String(data.message || '').slice(0, 2000),
|
||||
name: String(data.name || 'Anonymous').slice(0, 100),
|
||||
asn: data.asn ? String(data.asn).slice(0, 20) : null,
|
||||
ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null,
|
||||
ua: String(req.headers['user-agent'] || '').slice(0, 200)
|
||||
};
|
||||
let entries = [];
|
||||
try { entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8')); } catch (_e) { /* no file yet */ }
|
||||
entries.push(entry);
|
||||
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 }));
|
||||
} catch (_e) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ ok: false, error: 'Server error' }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/feedback?token=... — admin: fetch all entries as JSON
|
||||
function handleGet(req, res, url) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token || token !== FEEDBACK_TOKEN) {
|
||||
res.writeHead(401);
|
||||
return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
|
||||
}
|
||||
try {
|
||||
const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8'));
|
||||
return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length }));
|
||||
} catch (_e) {
|
||||
return res.end(JSON.stringify({ ok: true, entries: [], count: 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleOptions, handlePost, handleGet };
|
||||
111
server/routes/health.js
Normal file
111
server/routes/health.js
Normal file
@ -0,0 +1,111 @@
|
||||
const localDb = require("../../local-db-client");
|
||||
const { getRpkiAspaLastFetch, rpkiAspaMap } = require("../services/rpki");
|
||||
const { pdbSourceCache } = require("../caches/pdb-source-cache");
|
||||
const { roaStore } = require("../caches/roa-store");
|
||||
const { ripeStatCache } = require("../services/ripe-stat");
|
||||
const { responseCache } = require("../caches/response-caches");
|
||||
const { aspaAdoptionDailyHistory } = require("../aspa-adoption/tracker");
|
||||
|
||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||
|
||||
function aspaAdoptionDelta() {
|
||||
return aspaAdoptionDailyHistory.length >= 2
|
||||
? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Health endpoint — extended with cache status, ASPA metrics, and local DB stats
|
||||
function handleHealth(req, res) {
|
||||
const mem = process.memoryUsage();
|
||||
const rpkiAspaLastFetch = getRpkiAspaLastFetch();
|
||||
const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
|
||||
// Bug fix 2026-07-17: previously only declared inside the .then() callback
|
||||
// below, so the .catch() branch's reference to it threw ReferenceError
|
||||
// (never caught -- the response just hung until client timeout).
|
||||
const roaAge = roaStore.lastBuild ? Math.floor((Date.now() - roaStore.lastBuild) / 60000) : -1;
|
||||
const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
|
||||
|
||||
// Query local DB stats (async, but return partial if needed)
|
||||
localDb.getLocalDbStats().then(function(dbStats) {
|
||||
// hasLocalBgp/hasLocalRpki reflect a >2026-04 prototype (bgp_routes/
|
||||
// rpki_roas import pipeline) confirmed vestigial 2026-07-17: both import
|
||||
// timers have run "successfully" daily since creation while doing zero
|
||||
// real work (dead source URL / silent hardcoded-fallback), and no
|
||||
// user-facing endpoint reads from this local DB -- only this health
|
||||
// check and the unused /api/bgp route do. Still reported below as
|
||||
// diagnostic info, but no longer allowed to hold overall `status` at a
|
||||
// permanent false "degraded" for a dependency nothing actually needs.
|
||||
const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
|
||||
const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
|
||||
const status = aspaAge < 300 ? "ok" : "degraded";
|
||||
|
||||
const healthResponse = {
|
||||
status,
|
||||
service: "PeerCortex",
|
||||
version: "0.6.9",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
|
||||
bgproutes_configured: !!BGPROUTES_API_KEY,
|
||||
caches: {
|
||||
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
|
||||
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
|
||||
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
|
||||
pdb_netfac: { entries: pdbSourceCache.netfac.size },
|
||||
ripe_stat: { entries: ripeStatCache.size },
|
||||
response_cache: { entries: responseCache.size },
|
||||
},
|
||||
local_db: dbStats ? {
|
||||
bgp_routes: dbStats.bgp_routes,
|
||||
rpki_roas: dbStats.rpki_roas,
|
||||
threat_intel: dbStats.threat_intel,
|
||||
rdap_cache_entries: dbStats.rdap_cache_entries,
|
||||
source: "PostgreSQL (local)",
|
||||
healthy: hasLocalBgp && hasLocalRpki,
|
||||
} : null,
|
||||
aspa_adoption: {
|
||||
total_objects: rpkiAspaMap.size,
|
||||
roa_count: roaStore.count,
|
||||
history_samples: aspaAdoptionDailyHistory.length,
|
||||
delta_last: aspaAdoptionDelta(),
|
||||
},
|
||||
};
|
||||
return res.end(JSON.stringify(healthResponse, null, 2));
|
||||
}).catch(function(e) {
|
||||
console.error('[/api/health] Local DB stats error:', e.message);
|
||||
// Return health without local DB stats on error. Local DB stats failed
|
||||
// to fetch, so "ok" can't be confirmed -- report degraded, matching the
|
||||
// honest-degradation pattern used throughout this codebase.
|
||||
const status = "degraded";
|
||||
return res.end(
|
||||
JSON.stringify({
|
||||
status,
|
||||
service: "PeerCortex",
|
||||
version: "0.6.9",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
|
||||
bgproutes_configured: !!BGPROUTES_API_KEY,
|
||||
caches: {
|
||||
roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
|
||||
aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
|
||||
pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
|
||||
pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
|
||||
pdb_netfac: { entries: pdbSourceCache.netfac.size },
|
||||
ripe_stat: { entries: ripeStatCache.size },
|
||||
response_cache: { entries: responseCache.size },
|
||||
},
|
||||
local_db: { error: "Could not fetch local DB stats", message: e.message },
|
||||
aspa_adoption: {
|
||||
total_objects: rpkiAspaMap.size,
|
||||
roa_count: roaStore.count,
|
||||
history_samples: aspaAdoptionDailyHistory.length,
|
||||
delta_last: aspaAdoptionDelta(),
|
||||
},
|
||||
}, null, 2)
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = { handleHealth };
|
||||
221
server/routes/hijack-alerts.js
Normal file
221
server/routes/hijack-alerts.js
Normal file
@ -0,0 +1,221 @@
|
||||
const crypto = require("crypto");
|
||||
const {
|
||||
loadHijackSubs, loadHijackAlerts, loadWebhookSubs,
|
||||
saveWebhookSubs, saveHijackAlerts, saveHijackSubs,
|
||||
} = require("../hijack-monitoring/store");
|
||||
const { deliverWebhook } = require("../hijack-monitoring/webhooks");
|
||||
const { checkHijacksForAsn } = require("../hijack-monitoring/monitor");
|
||||
|
||||
function matchesHijackGroup(reqPath) {
|
||||
return reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe';
|
||||
}
|
||||
|
||||
// CORS preflight for all /api/webhooks + /api/hijacks
|
||||
function handleOptions(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.writeHead(204); return res.end();
|
||||
}
|
||||
|
||||
// Webhook: Register. POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
|
||||
function handleWebhookRegister(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
||||
if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); }
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}');
|
||||
if (!endpoint_url || !endpoint_url.startsWith('http')) {
|
||||
res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'}));
|
||||
}
|
||||
const subs = loadWebhookSubs();
|
||||
const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url);
|
||||
if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); }
|
||||
const webhookToken = crypto.randomBytes(32).toString('hex');
|
||||
const entry = {
|
||||
id: crypto.randomBytes(8).toString('hex'),
|
||||
asn, endpoint_url,
|
||||
secret: webhookToken,
|
||||
timeout_ms: timeout_ms || 10000,
|
||||
max_retries: max_retries || 5,
|
||||
active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
last_triggered_at: null,
|
||||
failure_count: 0
|
||||
};
|
||||
subs.push(entry);
|
||||
saveWebhookSubs(subs);
|
||||
// Also ensure this ASN is in hijack monitoring
|
||||
const hijackSubs = loadHijackSubs();
|
||||
if (!hijackSubs.find(s => s.asn === asn)) {
|
||||
checkHijacksForAsn(asn).then(prefixes => {
|
||||
// null (lookup failed) becomes [] here so this record's shape stays
|
||||
// consistent; runHijackCheck retries the real baseline next cycle.
|
||||
hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
||||
saveHijackSubs(hijackSubs);
|
||||
});
|
||||
}
|
||||
res.writeHead(201);
|
||||
// Bug fix 2026-07-17 (agreed pre-existing bug #1 of 3): responded with
|
||||
// `secret_key: secret`, but `secret` was never declared in this scope
|
||||
// (only `webhookToken` and `entry.secret`, the same value) -- every new
|
||||
// webhook registration threw ReferenceError here, caught below,
|
||||
// degrading to a 400 "secret is not defined". Use entry.secret, matching
|
||||
// the working "already exists" branch above.
|
||||
res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: entry.secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' }));
|
||||
} catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Webhook: List. GET /api/webhooks?asn=X
|
||||
function handleWebhookList(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
||||
const subs = loadWebhookSubs();
|
||||
const result = (asn ? subs.filter(s => s.asn === asn) : subs)
|
||||
.map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at }));
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ webhooks: result, total: result.length }));
|
||||
}
|
||||
|
||||
// Webhook: Delete. DELETE /api/webhooks/:id
|
||||
function handleWebhookDelete(req, res, reqPath) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const id = reqPath.split('/')[3];
|
||||
const subs = loadWebhookSubs();
|
||||
const idx = subs.findIndex(s => s.id === id);
|
||||
if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
|
||||
subs.splice(idx, 1);
|
||||
saveWebhookSubs(subs);
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ deleted: true, id }));
|
||||
}
|
||||
|
||||
// Webhook: Test. POST /api/webhooks/:id/test
|
||||
function handleWebhookTest(req, res, reqPath) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const id = reqPath.split('/')[3];
|
||||
const subs = loadWebhookSubs();
|
||||
const sub = subs.find(s => s.id === id);
|
||||
if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
|
||||
const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() };
|
||||
const start = Date.now();
|
||||
deliverWebhook(sub, testPayload, 1).then(result => {
|
||||
res.writeHead(result.ok ? 200 : 502);
|
||||
res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false
|
||||
function handleHijacksList(req, res) {
|
||||
const params = new URL(req.url, 'http://localhost').searchParams;
|
||||
const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
|
||||
const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);
|
||||
const resolvedFilter = params.get('resolved');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
let events = loadHijackAlerts();
|
||||
if (asn) events = events.filter(a => String(a.asn) === asn);
|
||||
if (resolvedFilter === 'false') events = events.filter(a => !a.resolved);
|
||||
if (resolvedFilter === 'true') events = events.filter(a => a.resolved);
|
||||
const total = events.length;
|
||||
events = events.slice(-limit).reverse();
|
||||
res.writeHead(200);
|
||||
return res.end(JSON.stringify({ total, events }));
|
||||
}
|
||||
|
||||
// Hijack Events: Resolve. POST /api/hijacks/:id/resolve body: { resolution_notes? }
|
||||
function handleHijackResolve(req, res, reqPath) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
const id = reqPath.split('/')[3];
|
||||
let body = '';
|
||||
req.on('data', c => body += c);
|
||||
req.on('end', () => {
|
||||
const alerts = loadHijackAlerts();
|
||||
const alert = alerts.find(a => a.id === id);
|
||||
if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); }
|
||||
alert.resolved = true;
|
||||
alert.resolved_at = new Date().toISOString();
|
||||
try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){}
|
||||
saveHijackAlerts(alerts);
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack Subscribe (legacy, kept for backwards compatibility)
|
||||
function handleHijackSubscribe(req, res) {
|
||||
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 } = 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);
|
||||
// prefixes may be null if the lookup failed just now -- store an empty
|
||||
// array so this record shape stays consistent; runHijackCheck's own
|
||||
// "!sub.prefixes.length" check will retry establishing the real
|
||||
// baseline on its next cycle rather than treating this as permanent.
|
||||
subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
|
||||
saveHijackSubs(subs);
|
||||
}
|
||||
const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
|
||||
} catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hijack Alerts (legacy endpoint, kept for backwards compatibility).
|
||||
// src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under
|
||||
// the same paths this file already serves above (file-backed, live).
|
||||
// Deliberately NOT proxied here — that would shadow working data with an
|
||||
// empty Postgres-backed implementation. Not a gap, just two feature
|
||||
// branches that ended up naming their routes the same thing.
|
||||
function handleHijackAlertsLegacy(req, res) {
|
||||
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 => String(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 }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
matchesHijackGroup,
|
||||
handleOptions,
|
||||
handleWebhookRegister,
|
||||
handleWebhookList,
|
||||
handleWebhookDelete,
|
||||
handleWebhookTest,
|
||||
handleHijacksList,
|
||||
handleHijackResolve,
|
||||
handleHijackSubscribe,
|
||||
handleHijackAlertsLegacy,
|
||||
};
|
||||
59
server/routes/ix-detail.js
Normal file
59
server/routes/ix-detail.js
Normal file
@ -0,0 +1,59 @@
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
|
||||
// IX Detail endpoint: /api/ix/detail?ix_id=X
|
||||
async function handleIxDetail(req, res, url) {
|
||||
const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
|
||||
if (!ixId) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const [ixData, ixlanData] = await Promise.all([
|
||||
fetchPeeringDB("/ix/" + ixId),
|
||||
fetchPeeringDB("/ixlan?ix_id=" + ixId),
|
||||
]);
|
||||
|
||||
const ix = ixData?.data?.[0] || {};
|
||||
const ixlans = ixlanData?.data || [];
|
||||
const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;
|
||||
|
||||
let members = [];
|
||||
if (ixlanId) {
|
||||
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
|
||||
members = (netixlanData?.data || []).map(m => ({
|
||||
asn: m.asn,
|
||||
name: m.name || "",
|
||||
speed_mbps: m.speed || 0,
|
||||
speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
|
||||
ipv4: m.ipaddr4 || null,
|
||||
ipv6: m.ipaddr6 || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by speed desc for top members
|
||||
const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
return res.end(JSON.stringify({
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
ix: {
|
||||
id: parseInt(ixId),
|
||||
name: ix.name || "",
|
||||
city: ix.city || "",
|
||||
country: ix.country || "",
|
||||
website: ix.website || "",
|
||||
peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
|
||||
},
|
||||
total_members: members.length,
|
||||
top_members_by_speed: sorted.slice(0, 20),
|
||||
all_members: sorted,
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleIxDetail };
|
||||
98
server/routes/lia.js
Normal file
98
server/routes/lia.js
Normal file
@ -0,0 +1,98 @@
|
||||
const { atlasState } = require("../atlas-probes");
|
||||
const { pdbOrgState } = require("../pdb-org-countries");
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
const { cacheGet, cacheSet } = require("../caches/response-caches");
|
||||
|
||||
// Lia's Atlas Paradise: Atlas probe coverage endpoint
|
||||
function handleAtlasCoverage(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (!atlasState.cache) {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
|
||||
}
|
||||
return res.end(JSON.stringify(atlasState.cache, null, 2));
|
||||
}
|
||||
|
||||
// Lia's Paradise: File parsing endpoint (for binary uploads)
|
||||
function handleParseFile(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
let body = "";
|
||||
req.on("data", function(chunk) { body += chunk; });
|
||||
req.on("end", function() {
|
||||
try {
|
||||
var parsed = JSON.parse(body);
|
||||
var filename = parsed.filename || "";
|
||||
var ext = filename.split(".").pop().toLowerCase();
|
||||
// For text-based formats, decode base64 and extract text
|
||||
if (ext === "csv" || ext === "txt") {
|
||||
var text = Buffer.from(parsed.data, "base64").toString("utf8");
|
||||
return res.end(JSON.stringify({ text: text }));
|
||||
}
|
||||
// For binary formats (PDF, XLS, DOC), we can't parse server-side without
|
||||
// heavy dependencies. Return helpful error.
|
||||
return res.end(JSON.stringify({
|
||||
error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.",
|
||||
suggestion: "Export your spreadsheet as CSV first, then upload the CSV file."
|
||||
}));
|
||||
} catch(e) {
|
||||
return res.end(JSON.stringify({ error: "Parse error: " + e.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Lia's Paradise: Combined PeeringDB + Atlas coverage data
|
||||
function handleLiaCoverage(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (!atlasState.cache) {
|
||||
res.writeHead(503);
|
||||
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
|
||||
}
|
||||
|
||||
// Cache this expensive response for 30 min
|
||||
var liaCacheKey = "lia_coverage";
|
||||
var liaCached = cacheGet(liaCacheKey);
|
||||
if (liaCached) return res.end(liaCached);
|
||||
|
||||
// Fetch PeeringDB network list (all networks with status "ok")
|
||||
// Use pre-cached org→country map (loaded at startup, 16MB response cached in memory)
|
||||
fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
|
||||
if (!pdbData || !pdbData.data) {
|
||||
return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
|
||||
}
|
||||
|
||||
var probeAsns = new Set(atlasState.cache.asns_with_probes || []);
|
||||
|
||||
var enriched = pdbData.data.map(function(n) {
|
||||
var org = pdbOrgState.map.get(n.org_id) || {};
|
||||
var cc = org.country || "";
|
||||
return {
|
||||
asn: n.asn,
|
||||
name: n.name || "",
|
||||
org_name: org.name || "",
|
||||
country: cc,
|
||||
country_name: cc,
|
||||
info_type: n.info_type || "",
|
||||
has_probe: probeAsns.has(n.asn),
|
||||
};
|
||||
}).filter(function(n) { return n.asn > 0 && n.country; });
|
||||
|
||||
var result = JSON.stringify({
|
||||
networks: enriched,
|
||||
total: enriched.length,
|
||||
with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
|
||||
without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
|
||||
atlas_unique_asns: probeAsns.size,
|
||||
org_countries_loaded: pdbOrgState.map.size,
|
||||
fetched_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
cacheSet(liaCacheKey, result, 30 * 60 * 1000);
|
||||
res.end(result);
|
||||
}).catch(function(e) {
|
||||
res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = { handleAtlasCoverage, handleParseFile, handleLiaCoverage };
|
||||
90
server/routes/lookup/cross-check.js
Normal file
90
server/routes/lookup/cross-check.js
Normal file
@ -0,0 +1,90 @@
|
||||
const { crossCheckRpki } = require("../../services/rpki");
|
||||
|
||||
// Multi-source cross-checks (RPKI vs RIPE Validator, prefix/neighbour counts
|
||||
// vs bgp.he.net) plus the overall data-quality summary derived from them.
|
||||
async function computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData) {
|
||||
let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
|
||||
let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
|
||||
let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
|
||||
|
||||
try {
|
||||
// RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
|
||||
const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
|
||||
const rpkiCrossResult = await Promise.race([
|
||||
rpkiCrossPromise,
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
|
||||
]);
|
||||
if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
|
||||
} catch (_e) { /* cross-check failed, keep defaults */ }
|
||||
|
||||
// bgp.he.net fetching is deliberately disabled (see the hardcoded
|
||||
// Promise.resolve(null) in index.js's timedFetch("bgp.he.net", ...)) --
|
||||
// bgpHeData is therefore always null and this whole branch, along with the
|
||||
// prefix/neighbour cross-checks below, never actually runs. Left in place
|
||||
// (not deleted) in case bgp.he.net fetching is re-enabled later, but
|
||||
// overall_confidence below must not claim a 2-source agreement that never
|
||||
// happened -- see the sources:1/2 handling further down.
|
||||
// Prefix count cross-check: compare RIPE Stat vs bgp.he.net
|
||||
if (bgpHeData) {
|
||||
const heV4 = bgpHeData.prefixes_v4 || 0;
|
||||
const heV6 = bgpHeData.prefixes_v6 || 0;
|
||||
const heTotal = heV4 + heV6;
|
||||
if (heTotal > 0) {
|
||||
prefixCrossCheck.bgp_he_net = heTotal;
|
||||
const ripeStat = prefixes.length;
|
||||
if (ripeStat > 0 && heTotal > 0) {
|
||||
const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
|
||||
prefixCrossCheck.agreement = ratio >= 0.9;
|
||||
const diff = Math.abs(ripeStat - heTotal);
|
||||
prefixCrossCheck.note = diff === 0
|
||||
? "Exact match"
|
||||
: "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
|
||||
}
|
||||
} else {
|
||||
prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
|
||||
}
|
||||
|
||||
// Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
|
||||
if (bgpHeData.peer_count != null) {
|
||||
neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
|
||||
}
|
||||
} else {
|
||||
prefixCrossCheck.note = "bgp.he.net data unavailable";
|
||||
}
|
||||
|
||||
// Compute overall data quality. Only push a score for a cross-check that
|
||||
// actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
|
||||
// not silently count as a perfect 100 in the average, and confidence must
|
||||
// reflect "nothing was actually cross-checked" rather than defaulting high.
|
||||
const crossCheckScores = [];
|
||||
if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
|
||||
// Prefix agreement: convert to percentage
|
||||
if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
|
||||
const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
|
||||
crossCheckScores.push(Math.round(pfxRatio * 100));
|
||||
}
|
||||
// Neighbour agreement
|
||||
if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
|
||||
const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
|
||||
crossCheckScores.push(Math.round(nbrRatio * 100));
|
||||
}
|
||||
const avgAgreement = crossCheckScores.length > 0
|
||||
? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
|
||||
: null;
|
||||
const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
|
||||
|
||||
const dataQuality = {
|
||||
sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
|
||||
cross_checks: {
|
||||
rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
|
||||
prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
|
||||
neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
|
||||
},
|
||||
overall_confidence: overallConfidence,
|
||||
overall_agreement_pct: avgAgreement,
|
||||
};
|
||||
|
||||
return { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality };
|
||||
}
|
||||
|
||||
module.exports = { computeCrossChecks };
|
||||
73
server/routes/lookup/enrich-neighbours.js
Normal file
73
server/routes/lookup/enrich-neighbours.js
Normal file
@ -0,0 +1,73 @@
|
||||
const localDb = require("../../../local-db-client");
|
||||
const { fetchRipeStatCached } = require("../../services/ripe-stat");
|
||||
|
||||
// Resolve empty AS names — all in parallel, with 3s timeout. Mutates entries
|
||||
// in place (matches original control flow).
|
||||
async function resolveEmptyNames(neighbourLists) {
|
||||
const emptyNameNeighbours = neighbourLists.flat().filter(n => !n.name);
|
||||
if (emptyNameNeighbours.length === 0) return;
|
||||
const resolvePromise = Promise.all(
|
||||
emptyNameNeighbours.map(n =>
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
|
||||
.then(r => { if (r?.data?.holder) n.name = r.data.holder; })
|
||||
.catch(() => {})
|
||||
)
|
||||
);
|
||||
await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
|
||||
}
|
||||
|
||||
// Threat Intelligence Enrichment for Neighbors: enrich neighbor data with
|
||||
// threat status from the local threat_intel table. Batch lookups (cap at 50
|
||||
// to avoid overwhelming DB), 4s timeout.
|
||||
async function buildThreatMap(neighbors) {
|
||||
const threatMap = {};
|
||||
const toCheck = neighbors.slice(0, 50);
|
||||
const threatPromises = toCheck.map(async (n) => {
|
||||
try {
|
||||
const asNum = String(n.asn);
|
||||
const threat = await localDb.getThreatIntel(asNum);
|
||||
if (threat) {
|
||||
threatMap[n.asn] = {
|
||||
threat_level: threat.threat_level,
|
||||
confidence_score: threat.confidence_score,
|
||||
source: threat.source,
|
||||
cached_at: threat.cached_at,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Gracefully skip on error
|
||||
console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(threatPromises),
|
||||
new Promise(r => setTimeout(r, 4000)),
|
||||
]);
|
||||
|
||||
return threatMap;
|
||||
}
|
||||
|
||||
function attachThreat(n, threatMap) {
|
||||
return {
|
||||
...n,
|
||||
threat_level: threatMap[n.asn]?.threat_level || null,
|
||||
threat_confidence: threatMap[n.asn]?.confidence_score || null,
|
||||
threat_source: threatMap[n.asn]?.source || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Full enrichment pipeline: resolve empty names, then attach threat intel to
|
||||
// upstreams/downstreams/peers. Returns new arrays (does not mutate the input
|
||||
// arrays themselves, though resolveEmptyNames does mutate entry.name).
|
||||
async function enrichNeighbours(upstreams, downstreams, peers) {
|
||||
await resolveEmptyNames([upstreams, downstreams, peers]);
|
||||
const threatMap = await buildThreatMap([...upstreams, ...downstreams, ...peers]);
|
||||
return {
|
||||
upstreams: upstreams.map(n => attachThreat(n, threatMap)),
|
||||
downstreams: downstreams.map(n => attachThreat(n, threatMap)),
|
||||
peers: peers.map(n => attachThreat(n, threatMap)),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { enrichNeighbours };
|
||||
114
server/routes/lookup/facilities.js
Normal file
114
server/routes/lookup/facilities.js
Normal file
@ -0,0 +1,114 @@
|
||||
const { fetchPeeringDB } = require("../../services/peeringdb");
|
||||
const { CITY_COORDS } = require("../../data/city-coords");
|
||||
const { IX_CITY_MAP } = require("../../data/ix-city-map");
|
||||
|
||||
// Batch-fetch facility lat/lon by ID, 25 per request (PeeringDB /fac?id__in= limit),
|
||||
// bounded by an overall race timeout. Returns {fac_id: {lat, lon}}.
|
||||
async function fetchFacCoordsBatch(facIds, timeoutMs) {
|
||||
const coordMap = {};
|
||||
if (facIds.length === 0) return coordMap;
|
||||
try {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
|
||||
const coordResults = await Promise.race([
|
||||
Promise.all(chunks.map(chunk =>
|
||||
fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
|
||||
)),
|
||||
new Promise(r => setTimeout(() => r([]), timeoutMs)),
|
||||
]);
|
||||
(coordResults || []).forEach(res => {
|
||||
(res?.data || []).forEach(f => { if (f.latitude && f.longitude) coordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
|
||||
});
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
return coordMap;
|
||||
}
|
||||
|
||||
// Resolve facility coordinates for the map, then IX locations via ixfac -> fac
|
||||
// coordinates (max 20 IXs), then the name/CITY_COORDS/IX_CITY_MAP geocode
|
||||
// fallbacks for IXs PeeringDB has no facility coordinates for.
|
||||
async function resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections) {
|
||||
// Batch-fetch facility coordinates for map (max 50 facilities)
|
||||
const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
|
||||
let facCoordMap = await fetchFacCoordsBatch(facIds, 5000);
|
||||
|
||||
const facilities = facilitiesRaw.map(f => ({
|
||||
...f,
|
||||
latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
|
||||
longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
|
||||
}));
|
||||
|
||||
// Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
|
||||
const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
|
||||
let ixLocations = [];
|
||||
if (uniqueIxIds.length > 0) {
|
||||
try {
|
||||
const ixFacData = await Promise.race([
|
||||
fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
|
||||
new Promise(r => setTimeout(() => r(null), 5000)),
|
||||
]);
|
||||
const ixFacs = ixFacData?.data || [];
|
||||
// Collect unique fac_ids we don't already have coords for
|
||||
const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
|
||||
if (extraFacIds.length > 0) {
|
||||
const extraCoords = await fetchFacCoordsBatch(extraFacIds, 4000);
|
||||
facCoordMap = { ...facCoordMap, ...extraCoords };
|
||||
}
|
||||
// Build IX locations: pick first facility with coords per IX
|
||||
const ixNameMap = {};
|
||||
ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
|
||||
const seenIx = {};
|
||||
ixFacs.forEach(f => {
|
||||
if (seenIx[f.ix_id]) return;
|
||||
const coords = facCoordMap[f.fac_id];
|
||||
if (coords) {
|
||||
seenIx[f.ix_id] = true;
|
||||
ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
|
||||
}
|
||||
});
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
}
|
||||
|
||||
applyIxGeocodeFallback(ixLocations, ixConnections);
|
||||
|
||||
return { facilities, ixLocations };
|
||||
}
|
||||
|
||||
// === IX Location Geocode Fallback ===
|
||||
// Some IXPs have no facility coordinates in PeeringDB.
|
||||
// Use ix_name city extraction + hard-coded IX→city map as fallback.
|
||||
// Mutates ixLocations in place (matches original control flow).
|
||||
function applyIxGeocodeFallback(ixLocations, ixConnections) {
|
||||
var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
|
||||
ixConnections.forEach(function(conn) {
|
||||
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
||||
var name = conn.ix_name || "";
|
||||
if (name) {
|
||||
var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
|
||||
for (var w = 0; w < words.length; w++) {
|
||||
if (CITY_COORDS[words[w]]) {
|
||||
ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
|
||||
ixIdsWithCoords.add(conn.ix_id);
|
||||
return;
|
||||
}
|
||||
if (w < words.length - 1) {
|
||||
var tw = words[w] + " " + words[w + 1];
|
||||
if (CITY_COORDS[tw]) {
|
||||
ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
|
||||
ixIdsWithCoords.add(conn.ix_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Hard-coded IX ID → city for well-known IXPs whose names don't contain city
|
||||
ixConnections.forEach(function(conn) {
|
||||
if (ixIdsWithCoords.has(conn.ix_id)) return;
|
||||
var city = IX_CITY_MAP[conn.ix_id];
|
||||
if (city && CITY_COORDS[city]) {
|
||||
ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { resolveFacilitiesAndIxLocations };
|
||||
343
server/routes/lookup/index.js
Normal file
343
server/routes/lookup/index.js
Normal file
@ -0,0 +1,343 @@
|
||||
const localDb = require("../../../local-db-client");
|
||||
const { fetchJSON } = require("../../services/http-helpers");
|
||||
const { fetchPeeringDB, fetchPeeringDBWithRetry } = require("../../services/peeringdb");
|
||||
const { ensureAspaCache, validateRPKIWithCache } = require("../../services/rpki");
|
||||
const { computeResilienceScore } = require("../../resilience-score");
|
||||
const { computeRouteLeakDetection } = require("../../route-leak");
|
||||
const { pdbSourceCache } = require("../../caches/pdb-source-cache");
|
||||
const {
|
||||
cacheGet, cacheSet, CACHE_TTL_LOOKUP, CACHE_TTL_LOOKUP_DEGRADED,
|
||||
rdapCacheGet, rdapCacheSet,
|
||||
} = require("../../caches/response-caches");
|
||||
const { resolveFacilitiesAndIxLocations } = require("./facilities");
|
||||
const { enrichNeighbours } = require("./enrich-neighbours");
|
||||
const { deriveRirAndCountry } = require("./rir-country");
|
||||
const { computeRoutingInfo } = require("./routing-info");
|
||||
const { computeCrossChecks } = require("./cross-check");
|
||||
const { pdbOrgState } = require("../../pdb-org-countries");
|
||||
|
||||
// Main lookup endpoint: /api/lookup?asn=X
|
||||
async function handleLookup(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
|
||||
}
|
||||
const asn = rawAsn;
|
||||
const cacheKey = "lookup:" + asn;
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// Phase 0: Get PDB net first — check L2 cache, then API with retry
|
||||
let pdbNet = pdbSourceCache.get("net", asn);
|
||||
if (!pdbNet) {
|
||||
pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
|
||||
if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
|
||||
}
|
||||
const net = pdbNet?.data?.[0] || {};
|
||||
const netId = net.id;
|
||||
|
||||
// Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
|
||||
const ixQuery = netId
|
||||
? "/netixlan?net_id=" + netId + "&limit=1000"
|
||||
: "/netixlan?asn=" + asn + "&limit=1000";
|
||||
const ixCacheKey = netId ? String(netId) : "asn:" + asn;
|
||||
|
||||
// Check PDB source cache for IX/Fac data
|
||||
let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
|
||||
let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
|
||||
|
||||
// Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
|
||||
const sourceTiming = {};
|
||||
function timedFetch(name, promise) {
|
||||
const ts = Date.now();
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
|
||||
])
|
||||
.then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
|
||||
.catch(function() { sourceTiming[name] = null; return null; });
|
||||
}
|
||||
|
||||
const pocQuery = netId ? "/poc?net_id=" + netId + "&limit=25" : null;
|
||||
|
||||
// RDAP: check module-level cache first, only hit RIR endpoints on cache miss
|
||||
const rdapCached = rdapCacheGet(asn);
|
||||
const rdapPromise = rdapCached !== undefined
|
||||
? Promise.resolve(rdapCached)
|
||||
: Promise.race([
|
||||
...["https://rdap.db.ripe.net/autnum/"+asn, "https://rdap.arin.net/registry/autnum/"+asn,
|
||||
"https://rdap.apnic.net/autnum/"+asn, "https://rdap.lacnic.net/rdap/autnum/"+asn,
|
||||
"https://rdap.afrinic.net/rdap/autnum/"+asn].map(url =>
|
||||
fetchJSON(url, { timeout: 4000 })
|
||||
.then(d => (d && !d.errorCode && d.handle) ? d : new Promise(() => {}))
|
||||
.catch(() => new Promise(() => {}))
|
||||
),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 5000)),
|
||||
]).then(d => { rdapCacheSet(asn, d); return d; });
|
||||
|
||||
const promises = [
|
||||
timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat RIR", Promise.resolve(null)),
|
||||
timedFetch("RIPE Atlas", Promise.resolve(null)),
|
||||
timedFetch("bgp.he.net", Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
|
||||
timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
|
||||
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", rdapPromise),
|
||||
];
|
||||
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
|
||||
if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
|
||||
if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
|
||||
|
||||
// local-db-client's getRipeStat* functions return status:"error" (not "ok") when
|
||||
// the local Postgres DB query itself failed -- track which sources actually
|
||||
// failed so we don't cache a DB hiccup's fake-empty result as if it were a
|
||||
// genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
|
||||
const degradedSources = [
|
||||
["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
|
||||
["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
|
||||
["RIPE Stat PrefixSize", prefixSizeData],
|
||||
].filter(([, v]) => v && v.status === "error").map(([name]) => name);
|
||||
|
||||
const prefixes = prefixData?.data?.prefixes || [];
|
||||
const neighbours = neighbourData?.data?.neighbours || [];
|
||||
const overview = overviewData?.data || {};
|
||||
const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
|
||||
|
||||
// Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
|
||||
const atlasProbes = atlasProbeData?.results || [];
|
||||
const atlasConnected = atlasProbes.filter(p => {
|
||||
const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
|
||||
return sName === "connected";
|
||||
});
|
||||
const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
|
||||
|
||||
// RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
|
||||
await ensureAspaCache();
|
||||
const allPrefixes = prefixes.map((p) => p.prefix);
|
||||
const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
|
||||
|
||||
const ixConnections = (ixlanData?.data || [])
|
||||
.map((ix) => ({
|
||||
ix_name: ix.name || "",
|
||||
ix_id: ix.ix_id,
|
||||
speed_mbps: ix.speed || 0,
|
||||
ipv4: ix.ipaddr4 || null,
|
||||
ipv6: ix.ipaddr6 || null,
|
||||
city: ix.city || "",
|
||||
is_rs_peer: ix.is_rs_peer === true,
|
||||
}))
|
||||
.sort((a, b) => b.speed_mbps - a.speed_mbps);
|
||||
|
||||
const facilitiesRaw = (facData?.data || []).map((f) => ({
|
||||
fac_id: f.fac_id,
|
||||
name: f.name || "",
|
||||
city: f.city || "",
|
||||
country: f.country || "",
|
||||
}));
|
||||
|
||||
const { facilities, ixLocations } = await resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections);
|
||||
|
||||
const rpkiStatuses = rpkiAllResults;
|
||||
const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
|
||||
const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
|
||||
const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
|
||||
const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
|
||||
// Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
|
||||
// must not lower a network's displayed RPKI coverage percentage.
|
||||
const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
|
||||
const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
|
||||
|
||||
let upstreams = neighbours
|
||||
.filter((n) => n.type === "left")
|
||||
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
||||
.sort((a, b) => b.power - a.power);
|
||||
let downstreams = neighbours
|
||||
.filter((n) => n.type === "right")
|
||||
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
||||
.sort((a, b) => b.power - a.power);
|
||||
let peers = neighbours
|
||||
.filter((n) => n.type === "uncertain" || n.type === "peer")
|
||||
.map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
|
||||
.sort((a, b) => b.power - a.power);
|
||||
|
||||
const enriched = await enrichNeighbours(upstreams, downstreams, peers);
|
||||
upstreams = enriched.upstreams;
|
||||
downstreams = enriched.downstreams;
|
||||
peers = enriched.peers;
|
||||
|
||||
const { rir, country: derivedCountry } = deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData);
|
||||
// "RIPE Stat RIR" and "bgp.he.net" sources above are stubbed to always
|
||||
// resolve null (see Phase 1 promises), so derivedCountry is currently
|
||||
// never populated -- fall back to PeeringDB's org-level country map
|
||||
// (pdbOrgState, loaded at startup), the same source lia.js already uses.
|
||||
const country = derivedCountry || (pdbOrgState.map.get(net.org_id) || {}).country || "";
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
const routingInfo = await computeRoutingInfo(prefixes, visibilityData, prefixSizeData);
|
||||
|
||||
const { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality } =
|
||||
await computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData);
|
||||
|
||||
const result = {
|
||||
meta: {
|
||||
service: "PeerCortex",
|
||||
version: "0.6.9",
|
||||
query: "AS" + asn,
|
||||
duration_ms: duration,
|
||||
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
|
||||
timestamp: new Date().toISOString(),
|
||||
rpki_prefixes_checked: rpkiTotal,
|
||||
total_prefixes: prefixes.length,
|
||||
degraded_sources: degradedSources,
|
||||
},
|
||||
network: {
|
||||
asn: parseInt(asn),
|
||||
name: net.name || overview?.holder || (bgpHeData && bgpHeData.name_from_title) || "Unknown",
|
||||
aka: net.aka || "",
|
||||
org_name: (net.org && net.org.name) ? net.org.name : "",
|
||||
website: net.website || "",
|
||||
type: net.info_type || "",
|
||||
policy: net.policy_general || "",
|
||||
traffic: net.info_traffic || "",
|
||||
ratio: net.info_ratio || "",
|
||||
scope: net.info_scope || "",
|
||||
notes: net.notes ? net.notes.substring(0, 500) : "",
|
||||
peeringdb_id: netId || null,
|
||||
rir: rir,
|
||||
country: country,
|
||||
city: net.city || "",
|
||||
latitude: (net.latitude != null) ? net.latitude : null,
|
||||
longitude: (net.longitude != null) ? net.longitude : null,
|
||||
looking_glass: net.looking_glass || "",
|
||||
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: {
|
||||
total: prefixes.length,
|
||||
ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
|
||||
ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
|
||||
list: prefixes.map((p) => p.prefix),
|
||||
cross_check: prefixCrossCheck,
|
||||
},
|
||||
rpki: {
|
||||
coverage_percent: rpkiCoverage,
|
||||
valid: rpkiValid,
|
||||
invalid: rpkiInvalid,
|
||||
not_found: rpkiNotFound,
|
||||
unavailable: rpkiUnavailable,
|
||||
checked: rpkiTotal,
|
||||
details: rpkiStatuses,
|
||||
cross_check: rpkiCrossCheck,
|
||||
},
|
||||
neighbours: {
|
||||
total: neighbours.length,
|
||||
upstream_count: upstreams.length,
|
||||
downstream_count: downstreams.length,
|
||||
peer_count: peers.length,
|
||||
upstreams: upstreams.slice(0, 20),
|
||||
downstreams: downstreams.slice(0, 20),
|
||||
peers: peers.slice(0, 20),
|
||||
cross_check: neighbourCrossCheck,
|
||||
},
|
||||
ix_presence: {
|
||||
total_connections: ixConnections.length,
|
||||
unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
|
||||
connections: ixConnections,
|
||||
},
|
||||
ix_locations: ixLocations,
|
||||
facilities: {
|
||||
total: facilities.length,
|
||||
list: facilities,
|
||||
},
|
||||
routing: routingInfo,
|
||||
resilience_score: computeResilienceScore(upstreams, peers, ixConnections, prefixes),
|
||||
route_leak: computeRouteLeakDetection(upstreams, downstreams, peers),
|
||||
bgp_he_net: bgpHeData || null,
|
||||
atlas: {
|
||||
total_probes: atlasProbes.length,
|
||||
connected: atlasConnected.length,
|
||||
disconnected: atlasProbes.length - atlasConnected.length,
|
||||
anchors: atlasAnchors.length,
|
||||
probes: atlasProbes.slice(0, 100).map(p => ({
|
||||
id: p.id,
|
||||
status: p.status_name || p.status || "Unknown",
|
||||
is_anchor: p.is_anchor || false,
|
||||
country: p.country_code || "",
|
||||
prefix_v4: p.prefix_v4 || "",
|
||||
prefix_v6: p.prefix_v6 || "",
|
||||
description: p.description || "",
|
||||
})),
|
||||
},
|
||||
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 : "",
|
||||
};
|
||||
})(),
|
||||
_provenance: {
|
||||
prefixes: { source: "RIPE Stat announced-prefixes", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net prefix count daily" },
|
||||
neighbours: { source: "RIPE Stat asn-neighbours", validation: "cross-validated", confidence: "high", note: "Cross-checked with bgp.he.net peer count daily" },
|
||||
rpki: { source: "Cloudflare RPKI + RIPE Validator", validation: "cross-validated", confidence: "high", note: "Two independent RPKI sources compared" },
|
||||
ix_presence: { source: "PeeringDB netixlan (local mirror)", validation: "cross-validated", confidence: "high", note: "PeeringDB mirror refreshed daily" },
|
||||
facilities: { source: "PeeringDB netfac (local mirror)", validation: "single-source", confidence: "medium" },
|
||||
bgp_he_net: { source: "bgp.he.net HTML scrape", validation: "single-source", confidence: "medium", note: "HTML scrape, no official API — may have parsing drift" },
|
||||
atlas: { source: "RIPE Atlas API", validation: "single-source", confidence: "medium", note: "Probe availability varies by region" },
|
||||
resilience_score: { source: "Computed from RIPE Stat + PeeringDB", validation: "computed", confidence: "high", note: "All inputs cross-validated daily" },
|
||||
route_leak: { source: "RIPE Stat asn-neighbours heuristic", validation: "heuristic", confidence: "medium", note: "Pattern-based, not real-time — false positives possible" },
|
||||
registration: { source: "RDAP (RIR registry)", validation: "single-source", confidence: "high" },
|
||||
contacts: { source: "PeeringDB POC API", validation: "single-source", confidence: "medium", note: "Subject to PeeringDB rate limiting" },
|
||||
},
|
||||
};
|
||||
|
||||
// Update duration to include cross-check time
|
||||
result.meta.duration_ms = Date.now() - start;
|
||||
|
||||
// A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
|
||||
// retry soon instead of repeating a degraded result to every visitor.
|
||||
cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
|
||||
res.end(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
const duration = Date.now() - start;
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
module.exports = { handleLookup };
|
||||
51
server/routes/lookup/rir-country.js
Normal file
51
server/routes/lookup/rir-country.js
Normal file
@ -0,0 +1,51 @@
|
||||
const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("../../data/rir-country-codes");
|
||||
|
||||
// Derive RIR and country through a cascade of fallback sources: RIPE Stat
|
||||
// rir-stats-country, RDAP port43, RDAP self-link, bgp.he.net country_code,
|
||||
// then a country-code-to-RIR table as a last resort.
|
||||
function deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData) {
|
||||
let rir = "";
|
||||
let country = "";
|
||||
// RIPE Stat rir-stats-country uses 'location' field (not 'country' or 'rir')
|
||||
if (Array.isArray(rirEntries) && rirEntries.length > 0) {
|
||||
country = rirEntries[0]?.location || rirEntries[0]?.country || "";
|
||||
rir = rirEntries[0]?.rir || "";
|
||||
}
|
||||
if (!rir && rirData?.data) {
|
||||
const rirField = rirData.data.rirs || [];
|
||||
if (rirField.length > 0) rir = rirField[0]?.rir || "";
|
||||
}
|
||||
// Derive RIR from rdapData.port43 (e.g. "whois.ripe.net" → "RIPE")
|
||||
if (!rir && rdapData && rdapData.port43) {
|
||||
const p43 = (rdapData.port43 || "").toLowerCase();
|
||||
if (p43.includes("ripe")) rir = "RIPE";
|
||||
else if (p43.includes("arin")) rir = "ARIN";
|
||||
else if (p43.includes("apnic")) rir = "APNIC";
|
||||
else if (p43.includes("lacnic")) rir = "LACNIC";
|
||||
else if (p43.includes("afrinic")) rir = "AFRINIC";
|
||||
}
|
||||
// Also derive RIR from RDAP links (URL of the RDAP endpoint that responded)
|
||||
if (!rir && rdapData && rdapData.links) {
|
||||
const selfLink = (rdapData.links.find(l => l.rel === "self") || {}).href || "";
|
||||
if (selfLink.includes("ripe")) rir = "RIPE";
|
||||
else if (selfLink.includes("arin")) rir = "ARIN";
|
||||
else if (selfLink.includes("apnic")) rir = "APNIC";
|
||||
else if (selfLink.includes("lacnic")) rir = "LACNIC";
|
||||
else if (selfLink.includes("afrinic")) rir = "AFRINIC";
|
||||
}
|
||||
// bgp.he.net country_code fallback (for unannounced/reserve ASNs)
|
||||
if (!country && bgpHeData && bgpHeData.country_code) {
|
||||
country = bgpHeData.country_code;
|
||||
}
|
||||
// Last resort: derive RIR from country code (common assignments)
|
||||
if (!rir && country) {
|
||||
if (ARIN_CC.has(country)) rir = "ARIN";
|
||||
else if (APNIC_CC.has(country)) rir = "APNIC";
|
||||
else if (LACNIC_CC.has(country)) rir = "LACNIC";
|
||||
else if (AFRINIC_CC.has(country)) rir = "AFRINIC";
|
||||
else rir = "RIPE"; // Europe + rest = RIPE NCC
|
||||
}
|
||||
return { rir, country };
|
||||
}
|
||||
|
||||
module.exports = { deriveRirAndCountry };
|
||||
71
server/routes/lookup/routing-info.js
Normal file
71
server/routes/lookup/routing-info.js
Normal file
@ -0,0 +1,71 @@
|
||||
const { fetchBgproutesVisibility } = require("../../services/http-helpers");
|
||||
|
||||
// Compute routing visibility and prefix size distribution.
|
||||
async function computeRoutingInfo(prefixes, visibilityData, prefixSizeData) {
|
||||
const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
|
||||
const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
|
||||
var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;
|
||||
|
||||
// Visibility API returns per-RIS-collector data
|
||||
// Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
|
||||
// Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
|
||||
var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
|
||||
var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
|
||||
var visTimedOut = !visibilityData || !visibilityData.data;
|
||||
visibilities.forEach(function(v) {
|
||||
if (!v || !v.probe) return;
|
||||
var v4PeerCount = v.ipv4_full_table_peer_count || 0;
|
||||
var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
|
||||
var v6PeerCount = v.ipv6_full_table_peer_count || 0;
|
||||
var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
|
||||
v4Total += v4PeerCount;
|
||||
v4Seeing += (v4PeerCount - v4NotSeeing);
|
||||
v6Total += v6PeerCount;
|
||||
v6Seeing += (v6PeerCount - v6NotSeeing);
|
||||
});
|
||||
if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
|
||||
if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
|
||||
// If visibility API timed out but we have prefixes, try bgproutes.io fallback
|
||||
if (visTimedOut && prefixes.length > 0) {
|
||||
var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
|
||||
if (!fallbackPrefix) fallbackPrefix = prefixes[0];
|
||||
if (fallbackPrefix) {
|
||||
var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
|
||||
if (bgprFallback && bgprFallback.vps_seeing > 0) {
|
||||
// Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
|
||||
var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
|
||||
ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
|
||||
ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
|
||||
totalRisPeersV4 = bgprFallback.vps_seeing;
|
||||
console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
|
||||
} else {
|
||||
ipv4VisAvg = -1;
|
||||
ipv6VisAvg = -1;
|
||||
console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
|
||||
}
|
||||
} else {
|
||||
ipv4VisAvg = -1;
|
||||
ipv6VisAvg = -1;
|
||||
}
|
||||
}
|
||||
totalRisPeersV4 = v4Total;
|
||||
totalRisPeersV6 = v6Total;
|
||||
|
||||
// Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
|
||||
var psdData = (prefixSizeData && prefixSizeData.data) || {};
|
||||
var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
|
||||
var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
|
||||
|
||||
return {
|
||||
ipv4_prefixes: ipv4Prefixes.length,
|
||||
ipv6_prefixes: ipv6Prefixes.length,
|
||||
ipv4_visibility_avg: ipv4VisAvg,
|
||||
ipv6_visibility_avg: ipv6VisAvg,
|
||||
total_ris_peers_v4: totalRisPeersV4,
|
||||
total_ris_peers_v6: totalRisPeersV6,
|
||||
prefix_sizes_v4: psV4,
|
||||
prefix_sizes_v6: psV6,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { computeRoutingInfo };
|
||||
112
server/routes/pdf-export.js
Normal file
112
server/routes/pdf-export.js
Normal file
@ -0,0 +1,112 @@
|
||||
const http = require("http");
|
||||
const { buildPdfHtml, buildComparisonPdfHtml } = require("../pdf-export/templates");
|
||||
const { renderHtmlToPdf, pdfCacheGet, pdfCacheSet } = require("../pdf-export/renderer");
|
||||
|
||||
const PORT = process.env.PORT || 3101;
|
||||
|
||||
// Fetch JSON from an internal endpoint on this same process (loopback) --
|
||||
// re-triggers the full HTTP/caching/rate-limit path for /api/validate,
|
||||
// /api/aspa, /api/lookup rather than calling their handler logic directly.
|
||||
async function fetchInternal(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
|
||||
const req2 = http.request(opts, res2 => {
|
||||
let body = '';
|
||||
res2.on('data', c => body += c);
|
||||
res2.on('end', () => {
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
|
||||
});
|
||||
});
|
||||
req2.on('error', reject);
|
||||
req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
|
||||
req2.end();
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/export/pdf?asn=X&format=report|executive|technical
|
||||
async function handleExportPdf(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
|
||||
const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
|
||||
? url.searchParams.get('format') : 'report';
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
|
||||
}
|
||||
const cacheKey = `pdf:${rawAsn}:${format}`;
|
||||
const cached = pdfCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
|
||||
return res.end(cached);
|
||||
}
|
||||
try {
|
||||
const [validateData, aspaData] = await Promise.all([
|
||||
fetchInternal(`/api/validate?asn=${rawAsn}`),
|
||||
fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
|
||||
]);
|
||||
if (validateData.error) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: validateData.error }));
|
||||
}
|
||||
const html = buildPdfHtml(validateData, aspaData, format);
|
||||
const pdfBuf = await renderHtmlToPdf(html);
|
||||
pdfCacheSet(cacheKey, pdfBuf);
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
|
||||
return res.end(pdfBuf);
|
||||
} catch (err) {
|
||||
console.error('[PDF] Error:', err.message);
|
||||
res.writeHead(503, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/export/pdf/compare?asn1=X&asn2=Y
|
||||
async function handleExportPdfCompare(req, res, url) {
|
||||
const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
|
||||
const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
|
||||
if (!asn1 || !asn2) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
|
||||
}
|
||||
if (asn1 === asn2) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
|
||||
}
|
||||
const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
|
||||
const cached = pdfCacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
|
||||
return res.end(cached);
|
||||
}
|
||||
try {
|
||||
const [v1, a1, l1, v2, a2, l2] = await Promise.all([
|
||||
fetchInternal(`/api/validate?asn=${asn1}`),
|
||||
fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
|
||||
fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
|
||||
fetchInternal(`/api/validate?asn=${asn2}`),
|
||||
fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
|
||||
fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
|
||||
]);
|
||||
if (v1.error || v2.error) {
|
||||
res.writeHead(400, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: v1.error || v2.error }));
|
||||
}
|
||||
const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
|
||||
const pdfBuf = await renderHtmlToPdf(html);
|
||||
pdfCacheSet(cacheKey, pdfBuf);
|
||||
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
|
||||
return res.end(pdfBuf);
|
||||
} catch (err) {
|
||||
console.error('[PDF] Error:', err.message);
|
||||
res.writeHead(503, {'Content-Type':'application/json'});
|
||||
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
// CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
|
||||
function handleExportOptions(req, res) {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
module.exports = { handleExportPdf, handleExportPdfCompare, handleExportOptions };
|
||||
106
server/routes/peers-find.js
Normal file
106
server/routes/peers-find.js
Normal file
@ -0,0 +1,106 @@
|
||||
const { fetchPeeringDB } = require("../services/peeringdb");
|
||||
|
||||
// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
|
||||
async function handlePeersFind(req, res, url) {
|
||||
const ixName = url.searchParams.get("ix") || "";
|
||||
const policy = url.searchParams.get("policy") || "";
|
||||
const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
|
||||
const netType = url.searchParams.get("type") || "";
|
||||
|
||||
if (!ixName) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Search for IX by name
|
||||
const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
|
||||
const ixResults = ixSearch?.data || [];
|
||||
if (ixResults.length === 0) {
|
||||
return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
|
||||
}
|
||||
|
||||
// Use first matching IX
|
||||
const ix = ixResults[0];
|
||||
const ixId = ix.id;
|
||||
|
||||
// Get ixlan for this IX
|
||||
const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
|
||||
const ixlans = ixlanData?.data || [];
|
||||
if (ixlans.length === 0) {
|
||||
return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
|
||||
}
|
||||
|
||||
const ixlanId = ixlans[0].id;
|
||||
|
||||
// Get all networks at this IX
|
||||
const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
|
||||
const netixlans = netixlanData?.data || [];
|
||||
|
||||
// Get unique net_ids
|
||||
const netIds = [...new Set(netixlans.map(n => n.net_id))];
|
||||
|
||||
// Fetch network details in batches
|
||||
const networks = [];
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
|
||||
const batch = netIds.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(nid => fetchPeeringDB("/net/" + nid))
|
||||
);
|
||||
batchResults.forEach(r => {
|
||||
if (r?.data?.[0]) networks.push(r.data[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter and rank
|
||||
let filtered = networks.map(net => {
|
||||
const nix = netixlans.filter(n => n.net_id === net.id);
|
||||
const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
|
||||
return {
|
||||
asn: net.asn,
|
||||
name: net.name || "",
|
||||
policy: net.policy_general || "",
|
||||
type: net.info_type || "",
|
||||
speed_mbps: maxSpeed,
|
||||
speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
|
||||
traffic: net.info_traffic || "",
|
||||
website: net.website || "",
|
||||
peeringdb_id: net.id,
|
||||
ipv4: nix[0]?.ipaddr4 || null,
|
||||
ipv6: nix[0]?.ipaddr6 || null,
|
||||
};
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
if (policy) {
|
||||
filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
|
||||
}
|
||||
if (minSpeed > 0) {
|
||||
filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
|
||||
}
|
||||
if (netType) {
|
||||
filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
|
||||
}
|
||||
|
||||
// Sort by speed desc
|
||||
filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);
|
||||
|
||||
// Also find common IXPs for each match (check if they share other IXPs)
|
||||
const duration = Date.now() - start;
|
||||
return res.end(JSON.stringify({
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
|
||||
total_members: netixlans.length,
|
||||
filtered_count: filtered.length,
|
||||
matches: filtered.slice(0, 50),
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handlePeersFind };
|
||||
77
server/routes/prefix-detail.js
Normal file
77
server/routes/prefix-detail.js
Normal file
@ -0,0 +1,77 @@
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { fetchBgproutesVisibility } = require("../services/http-helpers");
|
||||
const { validateRPKIWithCache } = require("../services/rpki");
|
||||
|
||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||
|
||||
// Prefix Detail endpoint: /api/prefix/detail?prefix=X
|
||||
async function handlePrefixDetail(req, res, url) {
|
||||
const prefix = url.searchParams.get("prefix") || "";
|
||||
if (!prefix) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const [routingStatus, visibility] = await Promise.all([
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
|
||||
fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
|
||||
]);
|
||||
|
||||
const origins = routingStatus?.data?.origins || [];
|
||||
const firstSeen = routingStatus?.data?.first_seen?.time || null;
|
||||
|
||||
// RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
|
||||
let rpkiStatus = "unknown";
|
||||
let rpkiRoas = [];
|
||||
const originAsn = origins.length > 0 ? origins[0].asn : null;
|
||||
if (originAsn) {
|
||||
try {
|
||||
const localRpki = await validateRPKIWithCache(originAsn, prefix);
|
||||
rpkiStatus = localRpki.status;
|
||||
rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
|
||||
} catch (e) {
|
||||
console.error("[Prefix Detail] RPKI validation error:", e.message);
|
||||
rpkiStatus = "unknown";
|
||||
rpkiRoas = [];
|
||||
}
|
||||
}
|
||||
var visData = visibility?.data?.visibilities || [];
|
||||
var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
|
||||
var visibilitySource = "ripe_stat";
|
||||
// bgproutes.io fallback if RIPE Stat visibility returned no data
|
||||
if (visData.length === 0 && BGPROUTES_API_KEY) {
|
||||
var bgprVis = await fetchBgproutesVisibility(prefix);
|
||||
if (bgprVis && bgprVis.vps_seeing > 0) {
|
||||
risPeersSeeingIt = bgprVis.vps_seeing;
|
||||
visData = []; // keep empty, use risPeersSeeingIt
|
||||
visibilitySource = "bgproutes.io";
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get IRR data
|
||||
let irrStatus = "unknown";
|
||||
try {
|
||||
const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
|
||||
const records = whoisData?.data?.records || [];
|
||||
if (records.length > 0) irrStatus = "found";
|
||||
} catch(_e) {}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
return res.end(JSON.stringify({
|
||||
meta: { duration_ms: duration, timestamp: new Date().toISOString() },
|
||||
prefix: prefix,
|
||||
origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
|
||||
rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
|
||||
irr_status: irrStatus,
|
||||
visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
|
||||
first_seen: firstSeen,
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handlePrefixDetail };
|
||||
30
server/routes/proxy-stubs.js
Normal file
30
server/routes/proxy-stubs.js
Normal file
@ -0,0 +1,30 @@
|
||||
const { proxyToApiServer } = require("../services/api-proxy");
|
||||
|
||||
// Routes already migrated to the separate Fastify API server (src/api/ +
|
||||
// src/features/*/routes.ts) -- forwarded through rather than reimplemented
|
||||
// inline a second time. All mechanically identical (proxyToApiServer(req,
|
||||
// res, req.url)); handled as one data-driven dispatch instead of 12
|
||||
// near-duplicate one-line route handlers.
|
||||
const EXACT_PATHS = new Set([
|
||||
"/api/submarine-cables",
|
||||
"/api/global-infra",
|
||||
"/api/communities",
|
||||
"/api/irr-audit",
|
||||
"/api/asset-expand",
|
||||
"/api/rpki-history",
|
||||
"/api/aspath",
|
||||
"/api/looking-glass",
|
||||
"/api/ix-matrix",
|
||||
"/changelog-data",
|
||||
"/api/prefix-changes",
|
||||
]);
|
||||
|
||||
function isProxyStubPath(reqPath) {
|
||||
return EXACT_PATHS.has(reqPath) || reqPath.startsWith("/api/rib/");
|
||||
}
|
||||
|
||||
function handleProxyStub(req, res) {
|
||||
return proxyToApiServer(req, res, req.url);
|
||||
}
|
||||
|
||||
module.exports = { isProxyStubPath, handleProxyStub };
|
||||
48
server/routes/quick-ix.js
Normal file
48
server/routes/quick-ix.js
Normal file
@ -0,0 +1,48 @@
|
||||
const { fetchJSON } = require("../services/http-helpers");
|
||||
const { fetchRipeStatCached } = require("../services/ripe-stat");
|
||||
const { quickIxCacheGet, quickIxCacheSet } = require("../caches/response-caches");
|
||||
|
||||
// Quick-IX endpoint: /api/quick-ix?asn=X
|
||||
// Lightweight: only IX connections from PeeringDB, 1h cached
|
||||
// Used by Peering Recommendations to avoid 20x full lookups
|
||||
async function handleQuickIx(req, res, url) {
|
||||
const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
|
||||
if (!rawAsn) {
|
||||
res.writeHead(400);
|
||||
return res.end(JSON.stringify({ error: "Missing asn parameter" }));
|
||||
}
|
||||
const cached = quickIxCacheGet(rawAsn);
|
||||
if (cached !== undefined) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(cached));
|
||||
}
|
||||
try {
|
||||
const [pdbNetData, pdbIxlanData] = await Promise.all([
|
||||
fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null),
|
||||
fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null),
|
||||
]);
|
||||
const netName = pdbNetData?.data?.[0]?.name || "";
|
||||
const ixConnections = [];
|
||||
if (pdbIxlanData && pdbIxlanData.data) {
|
||||
pdbIxlanData.data.forEach((row) => {
|
||||
ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 });
|
||||
});
|
||||
}
|
||||
// Fall back to RIPE Stat if PeeringDB returns nothing
|
||||
if (ixConnections.length === 0) {
|
||||
const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null);
|
||||
const ixs = rsStat?.data?.ixs || [];
|
||||
ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 }));
|
||||
}
|
||||
const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections };
|
||||
quickIxCacheSet(rawAsn, result);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { handleQuickIx };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user