Compare commits
13 Commits
refactor/s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e2897a79 | ||
|
|
c0df5ff557 | ||
|
|
a4506de1c5 | ||
|
|
f72ce5f991 | ||
|
|
017b79709b | ||
|
|
50dc4e6b6a | ||
|
|
388c4f3c73 | ||
|
|
e2846dd8e8 | ||
|
|
dcd08accd5 | ||
|
|
4c286d7f2d | ||
|
|
381db11a5c | ||
|
|
a0ad05223e | ||
|
|
8d5f2fdd97 |
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()
|
||||||
23
.github/workflows/build-verify.yml
vendored
23
.github/workflows/build-verify.yml
vendored
@ -80,18 +80,11 @@ jobs:
|
|||||||
|
|
||||||
- name: vitest
|
- name: vitest
|
||||||
run: npm test
|
run: npm test
|
||||||
# KNOWN OPEN ISSUE (2026-07-16): this step fails in 0-1s on this specific
|
# Fixed 2026-07-17: the 0-1s pre-test-load crash on this runner was
|
||||||
# Gitea Actions runner for a reason not diagnosable from a local machine --
|
# package-lock.json missing per-platform optional-dependency entries
|
||||||
# tried forcing a small fixed forks pool (--pool=forks, min/maxForks) as a
|
# for Rollup/esbuild's native Linux binaries (npm/cli#4828) -- the
|
||||||
# guess (common fix for CI-only vitest worker-pool crashes), made no
|
# lockfile had only been regenerated on darwin-arm64, so `npm ci` on
|
||||||
# difference. The actual test suite passes 215/215 reliably every way
|
# the Linux runner silently skipped @rollup/rollup-linux-x64-gnu and
|
||||||
# tested locally: plain `npm test`, with CI=true set, and after a fully
|
# @esbuild/linux-x64, and Vite's synchronous require() of them at
|
||||||
# fresh `rm -rf node_modules && npm ci`. The job's "Complete job" step
|
# startup threw before any test file ran. Resolved as a side effect
|
||||||
# also shows failure/canRerun:false, which may indicate a runner-level
|
# of regenerating package-lock.json in commit a0ad052.
|
||||||
# issue rather than a test-code issue. Needs someone with actual
|
|
||||||
# logged-in dashboard access to this Gitea instance to read the real
|
|
||||||
# step log -- not accessible via this session's tooling (Gitea Actions
|
|
||||||
# log API returned 404 for every endpoint pattern tried; the web log
|
|
||||||
# viewer requires an authenticated session to stream content).
|
|
||||||
# Every OTHER step in this workflow (syntax, require() check, typecheck,
|
|
||||||
# build) is confirmed working and already caught 2 real bugs today.
|
|
||||||
|
|||||||
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
|
||||||
21
CHANGELOG.md
21
CHANGELOG.md
@ -4,6 +4,27 @@ 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
|
## v0.7.0 — 2026-04-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
@ -859,12 +859,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-07-16",
|
"date": "2026-07-16",
|
||||||
"ts": 1784174057994,
|
"ts": 1784233075736,
|
||||||
"aspa_objects": 2382,
|
"aspa_objects": 2396,
|
||||||
"roa_count": 971831,
|
"roa_count": 972051,
|
||||||
"atlas_asns_total": 4775,
|
"atlas_asns_total": 4772,
|
||||||
"atlas_asns_with_aspa": 504,
|
"atlas_asns_with_aspa": 506,
|
||||||
"coverage_pct_atlas": 10.55,
|
"coverage_pct_atlas": 10.6,
|
||||||
"aspa_delta": 0,
|
"aspa_delta": 0,
|
||||||
"method": "rpki_cloudflare_feed"
|
"method": "rpki_cloudflare_feed"
|
||||||
}
|
}
|
||||||
|
|||||||
53344
hijack-alerts.json
53344
hijack-alerts.json
File diff suppressed because it is too large
Load Diff
@ -101,10 +101,17 @@ async function validateRpki(prefix, originAsn) {
|
|||||||
|
|
||||||
const prefixLength = parseInt(prefixParts[1]);
|
const prefixLength = parseInt(prefixParts[1]);
|
||||||
|
|
||||||
// Query for covering ROAs
|
// 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(
|
const result = await pool.query(
|
||||||
`SELECT * FROM rpki_roas
|
`SELECT * FROM rpki_roas
|
||||||
WHERE $1::cidr << (prefix || '/' || max_length)::cidr
|
WHERE $1::cidr <<= prefix
|
||||||
AND origin_asn = $2
|
AND origin_asn = $2
|
||||||
AND expires > NOW()
|
AND expires > NOW()
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
@ -113,7 +120,7 @@ async function validateRpki(prefix, originAsn) {
|
|||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
const anyRoa = await pool.query(
|
const anyRoa = await pool.query(
|
||||||
`SELECT 1 FROM rpki_roas WHERE $1::cidr << prefix AND expires > NOW() LIMIT 1`,
|
`SELECT 1 FROM rpki_roas WHERE $1::cidr <<= prefix AND expires > NOW() LIMIT 1`,
|
||||||
[prefix]
|
[prefix]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
2028
package-lock.json
generated
2028
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "peercortex",
|
"name": "peercortex",
|
||||||
"version": "0.7.0",
|
"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.",
|
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
||||||
"main": "dist/mcp-server/index.js",
|
"main": "dist/mcp-server/index.js",
|
||||||
"types": "dist/mcp-server/index.d.ts",
|
"types": "dist/mcp-server/index.d.ts",
|
||||||
@ -67,7 +67,7 @@
|
|||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -110,6 +110,13 @@ const VOLATILE_PATHS = new Set([
|
|||||||
"ipv6-adoption-stats.total_ipv4_records",
|
"ipv6-adoption-stats.total_ipv4_records",
|
||||||
"ipv6-adoption-stats.total_ipv6_records",
|
"ipv6-adoption-stats.total_ipv6_records",
|
||||||
"ipv6-adoption-stats.by_rir",
|
"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) {
|
function stripNondeterministic(obj, pathPrefix) {
|
||||||
|
|||||||
@ -27,10 +27,17 @@ function handleHealth(req, res) {
|
|||||||
|
|
||||||
// Query local DB stats (async, but return partial if needed)
|
// Query local DB stats (async, but return partial if needed)
|
||||||
localDb.getLocalDbStats().then(function(dbStats) {
|
localDb.getLocalDbStats().then(function(dbStats) {
|
||||||
// Determine health status based on local DB data availability
|
// 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 hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
|
||||||
const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
|
const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
|
||||||
const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";
|
const status = aspaAge < 300 ? "ok" : "degraded";
|
||||||
|
|
||||||
const healthResponse = {
|
const healthResponse = {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const { enrichNeighbours } = require("./enrich-neighbours");
|
|||||||
const { deriveRirAndCountry } = require("./rir-country");
|
const { deriveRirAndCountry } = require("./rir-country");
|
||||||
const { computeRoutingInfo } = require("./routing-info");
|
const { computeRoutingInfo } = require("./routing-info");
|
||||||
const { computeCrossChecks } = require("./cross-check");
|
const { computeCrossChecks } = require("./cross-check");
|
||||||
|
const { pdbOrgState } = require("../../pdb-org-countries");
|
||||||
|
|
||||||
// Main lookup endpoint: /api/lookup?asn=X
|
// Main lookup endpoint: /api/lookup?asn=X
|
||||||
async function handleLookup(req, res, url) {
|
async function handleLookup(req, res, url) {
|
||||||
@ -177,7 +178,12 @@ async function handleLookup(req, res, url) {
|
|||||||
downstreams = enriched.downstreams;
|
downstreams = enriched.downstreams;
|
||||||
peers = enriched.peers;
|
peers = enriched.peers;
|
||||||
|
|
||||||
const { rir, country } = deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData);
|
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 duration = Date.now() - start;
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import Fastify from 'fastify'
|
import Fastify from 'fastify'
|
||||||
import { getDatabase } from '../lib/db'
|
import { getDatabase } from '../lib/db'
|
||||||
import { hijackAlertsRoutes } from '../routes/hijack-alerts'
|
|
||||||
import { pdfExportRoutes } from '../routes/pdf-export'
|
|
||||||
import { aspaAdoptionRoutes } from '../routes/aspa-adoption'
|
|
||||||
import { bgpCommunitiesRoutes } from '../features/bgp-communities/routes'
|
import { bgpCommunitiesRoutes } from '../features/bgp-communities/routes'
|
||||||
import { irrAuditRoutes } from '../features/irr-audit/routes'
|
import { irrAuditRoutes } from '../features/irr-audit/routes'
|
||||||
import { assetExpandRoutes } from '../features/asset-expand/routes'
|
import { assetExpandRoutes } from '../features/asset-expand/routes'
|
||||||
@ -15,7 +12,6 @@ import { submarineCablesRoutes } from '../features/submarine-cables/routes'
|
|||||||
import { globalInfraRoutes } from '../features/global-infra/routes'
|
import { globalInfraRoutes } from '../features/global-infra/routes'
|
||||||
import { changelogRoutes } from '../features/changelog/routes'
|
import { changelogRoutes } from '../features/changelog/routes'
|
||||||
import { ribRoutes } from '../features/rib/routes'
|
import { ribRoutes } from '../features/rib/routes'
|
||||||
import { hijackSubscribeRoutes } from '../features/hijack-subscribe/routes'
|
|
||||||
|
|
||||||
export async function createApiServer(_port: number = 3102) {
|
export async function createApiServer(_port: number = 3102) {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@ -24,10 +20,12 @@ export async function createApiServer(_port: number = 3102) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize routes
|
// Initialize routes. hijack-alerts/pdf-export/aspa-adoption/hijack-subscribe
|
||||||
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
|
// were removed 2026-07-17: never reachable in production (server/routes/
|
||||||
await fastify.register(pdfExportRoutes, { prefix: '/api' })
|
// proxy-stubs.js's EXACT_PATHS never proxied their paths here), and the
|
||||||
await fastify.register(aspaAdoptionRoutes, { prefix: '/api' })
|
// old file-backed server/routes/*.js handlers are the only ones actually
|
||||||
|
// serving these features. See src/features/{aspa-adoption,hijack-alerts,
|
||||||
|
// pdf-export}/ history if reviving a Postgres-backed version later.
|
||||||
await fastify.register(bgpCommunitiesRoutes, { prefix: '/api' })
|
await fastify.register(bgpCommunitiesRoutes, { prefix: '/api' })
|
||||||
await fastify.register(irrAuditRoutes, { prefix: '/api' })
|
await fastify.register(irrAuditRoutes, { prefix: '/api' })
|
||||||
await fastify.register(assetExpandRoutes, { prefix: '/api' })
|
await fastify.register(assetExpandRoutes, { prefix: '/api' })
|
||||||
@ -40,7 +38,6 @@ export async function createApiServer(_port: number = 3102) {
|
|||||||
await fastify.register(globalInfraRoutes, { prefix: '/api' })
|
await fastify.register(globalInfraRoutes, { prefix: '/api' })
|
||||||
await fastify.register(changelogRoutes)
|
await fastify.register(changelogRoutes)
|
||||||
await fastify.register(ribRoutes, { prefix: '/api' })
|
await fastify.register(ribRoutes, { prefix: '/api' })
|
||||||
await fastify.register(hijackSubscribeRoutes, { prefix: '/api' })
|
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
@ -58,7 +55,7 @@ export async function createApiServer(_port: number = 3102) {
|
|||||||
return {
|
return {
|
||||||
name: 'PeerCortex API',
|
name: 'PeerCortex API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
features: ['hijack-alerts', 'pdf-export', 'aspa-adoption-tracker', 'bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib', 'hijack-subscribe'],
|
features: ['bgp-communities', 'irr-audit', 'asset-expand', 'rpki-history', 'aspath', 'ix-matrix', 'looking-glass', 'prefix-changes', 'submarine-cables', 'global-infra', 'changelog', 'rib'],
|
||||||
docs: 'https://peercortex.org/docs',
|
docs: 'https://peercortex.org/docs',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -210,8 +210,10 @@ describe('Hijack Detector', () => {
|
|||||||
const elapsed = Date.now() - startTime
|
const elapsed = Date.now() - startTime
|
||||||
|
|
||||||
expect(result).toBe(baseDesc)
|
expect(result).toBe(baseDesc)
|
||||||
// AbortController timeout should trigger around 5-6 seconds
|
// AbortController timeout should trigger around 5-6 seconds. A few ms
|
||||||
expect(elapsed).toBeGreaterThanOrEqual(5000)
|
// of tolerance below 5000 absorbs normal setTimeout/Date.now() jitter
|
||||||
|
// between the two clock reads (observed flake: 4999ms).
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(4990)
|
||||||
expect(elapsed).toBeLessThan(7000)
|
expect(elapsed).toBeLessThan(7000)
|
||||||
}, { timeout: 10000 })
|
}, { timeout: 10000 })
|
||||||
|
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
// Path to legacy JSON file-based subscriptions (kept for backward compat)
|
|
||||||
const HIJACK_SUBS_FILE = process.env.HIJACK_SUBS_FILE || '/opt/peercortex-app/data/hijack-subs.json';
|
|
||||||
|
|
||||||
interface HijackSubscribeBody {
|
|
||||||
asn?: string | number;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadHijackSubs(): any[] {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(HIJACK_SUBS_FILE)) return [];
|
|
||||||
return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hijackSubscribeRoutes(fastify: FastifyInstance): Promise<void> {
|
|
||||||
// OPTIONS preflight
|
|
||||||
fastify.options('/hijack-subscribe', async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
reply.header('Access-Control-Allow-Origin', '*');
|
|
||||||
reply.header('Access-Control-Allow-Methods', 'POST,OPTIONS');
|
|
||||||
reply.header('Access-Control-Allow-Headers', 'Content-Type');
|
|
||||||
return reply.status(204).send();
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/hijack-subscribe
|
|
||||||
fastify.post<{ Body: HijackSubscribeBody }>(
|
|
||||||
'/hijack-subscribe',
|
|
||||||
async (request: FastifyRequest<{ Body: HijackSubscribeBody }>, reply: FastifyReply) => {
|
|
||||||
reply.header('Access-Control-Allow-Origin', '*');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = request.body as HijackSubscribeBody;
|
|
||||||
const asnNum = String(body.asn || '').replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
if (!asnNum) {
|
|
||||||
return reply.status(400).send({ error: 'asn required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subs = loadHijackSubs();
|
|
||||||
const exists = subs.find((s: any) => s.asn === asnNum);
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
// Add subscription without live hijack check (avoids circular dependency)
|
|
||||||
// The scheduler will pick it up on next cycle
|
|
||||||
subs.push({
|
|
||||||
asn: asnNum,
|
|
||||||
email: body.email || '',
|
|
||||||
prefixes: [],
|
|
||||||
subscribed: new Date().toISOString()
|
|
||||||
});
|
|
||||||
fs.mkdirSync(path.dirname(HIJACK_SUBS_FILE), { recursive: true });
|
|
||||||
fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(subs, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = exists || subs[subs.length - 1];
|
|
||||||
return reply.status(200).send({
|
|
||||||
ok: true,
|
|
||||||
asn: asnNum,
|
|
||||||
monitoring: true,
|
|
||||||
prefix_count: entry.prefixes?.length ?? 0
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
return reply.status(500).send({ error: e.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
|
||||||
import { ASPADatabaseClient } from '../features/aspa-adoption/db-client'
|
|
||||||
import { AdoptionStats } from '../features/aspa-adoption/types'
|
|
||||||
import { AdoptionForecaster } from '../features/aspa-adoption/forecaster'
|
|
||||||
|
|
||||||
const dbClient = new ASPADatabaseClient()
|
|
||||||
const forecaster = new AdoptionForecaster()
|
|
||||||
|
|
||||||
export async function aspaAdoptionRoutes(fastify: FastifyInstance) {
|
|
||||||
/**
|
|
||||||
* GET /api/aspa-adoption-stats
|
|
||||||
* Main adoption statistics with trend and forecast
|
|
||||||
*/
|
|
||||||
fastify.get<{
|
|
||||||
Querystring: { period?: string; region?: string }
|
|
||||||
}>('/aspa-adoption-stats', async (request: FastifyRequest<{ Querystring: { period?: string; region?: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const period = request.query.period || '30d'
|
|
||||||
const days = parsePeriod(period)
|
|
||||||
|
|
||||||
const latest = await dbClient.getLatestSnapshot()
|
|
||||||
if (!latest) {
|
|
||||||
return reply.code(404).send({ error: 'No ASPA adoption data available yet' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = await dbClient.getAdoptionHistory(days)
|
|
||||||
const timeSeriesData = history.reverse().map((h) => ({
|
|
||||||
date: h.sampleDate,
|
|
||||||
coverage: h.coveragePercentage,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const forecast = forecaster.forecast(timeSeriesData, {
|
|
||||||
historyDays: days,
|
|
||||||
forecastMonths: 6,
|
|
||||||
regressionAlpha: 0.05,
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats: AdoptionStats = {
|
|
||||||
current: {
|
|
||||||
coverage: latest.coveragePercentage,
|
|
||||||
trend: forecast.trend,
|
|
||||||
change24h: calculateChange24h(history),
|
|
||||||
},
|
|
||||||
trend: timeSeriesData.map((p) => ({
|
|
||||||
date: p.date.toISOString().split('T')[0],
|
|
||||||
coveragePercentage: p.coverage,
|
|
||||||
sampledASNs: latest.totalASNsSampled,
|
|
||||||
})),
|
|
||||||
forecast,
|
|
||||||
regions: latest.regions || [],
|
|
||||||
topAdopters: latest.topAdopters || [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching ASPA adoption stats:', error)
|
|
||||||
return reply.code(500).send({ error: 'Failed to fetch adoption statistics' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/aspa-adoption-stats/regional
|
|
||||||
* Regional adoption breakdown
|
|
||||||
*/
|
|
||||||
fastify.get('/aspa-adoption-stats/regional', async (_request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const latest = await dbClient.getLatestSnapshot()
|
|
||||||
if (!latest) {
|
|
||||||
return reply.code(404).send({ error: 'No regional data available' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const regions = (latest.regions || []).map((r) => ({
|
|
||||||
region: r.region,
|
|
||||||
coveragePercentage: r.coveragePercentage,
|
|
||||||
totalASNs: r.totalASNs,
|
|
||||||
ASNsWithASPA: r.ASNsWithASPA,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { regions }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching regional stats:', error)
|
|
||||||
return reply.code(500).send({ error: 'Failed to fetch regional statistics' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/aspa-adoption-stats/ixps
|
|
||||||
* IXP adoption breakdown
|
|
||||||
*/
|
|
||||||
fastify.get<{
|
|
||||||
Querystring: { top?: string }
|
|
||||||
}>('/aspa-adoption-stats/ixps', async (request: FastifyRequest<{ Querystring: { top?: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const limit = parseInt(request.query.top || '20', 10)
|
|
||||||
const latest = await dbClient.getLatestSnapshot()
|
|
||||||
|
|
||||||
if (!latest) {
|
|
||||||
return reply.code(404).send({ error: 'No IXP data available' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// IXP data would be stored in database in production
|
|
||||||
const ixps = [
|
|
||||||
{ ixpName: 'DE-CIX Frankfurt', coveragePercentage: 67.5, participants: 850, participantsWithASPA: 573 },
|
|
||||||
{ ixpName: 'AMS-IX Amsterdam', coveragePercentage: 62.3, participants: 720, participantsWithASPA: 448 },
|
|
||||||
{ ixpName: 'LINX London', coveragePercentage: 58.9, participants: 580, participantsWithASPA: 342 },
|
|
||||||
].slice(0, limit)
|
|
||||||
|
|
||||||
return { ixps }
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching IXP stats:', error)
|
|
||||||
return reply.code(500).send({ error: 'Failed to fetch IXP statistics' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/aspa-adoption-stats/export
|
|
||||||
* Export adoption data as CSV or JSON
|
|
||||||
*/
|
|
||||||
fastify.get<{
|
|
||||||
Querystring: { format?: 'csv' | 'json'; period?: string }
|
|
||||||
}>('/aspa-adoption-stats/export', async (request: FastifyRequest<{ Querystring: { format?: 'csv' | 'json'; period?: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const format = request.query.format || 'json'
|
|
||||||
const period = request.query.period || '30d'
|
|
||||||
const days = parsePeriod(period)
|
|
||||||
|
|
||||||
const history = await dbClient.getAdoptionHistory(days)
|
|
||||||
|
|
||||||
if (format === 'csv') {
|
|
||||||
const csv = convertToCSV(history)
|
|
||||||
return reply
|
|
||||||
.header('Content-Type', 'text/csv')
|
|
||||||
.header('Content-Disposition', 'attachment; filename="aspa-adoption.csv"')
|
|
||||||
.send(csv)
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
exportDate: new Date().toISOString(),
|
|
||||||
period,
|
|
||||||
totalRecords: history.length,
|
|
||||||
data: history,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting adoption data:', error)
|
|
||||||
return reply.code(500).send({ error: 'Failed to export data' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePeriod(period: string): number {
|
|
||||||
const match = period.match(/^(\d+)([dwmy])$/)
|
|
||||||
if (!match) return 30
|
|
||||||
|
|
||||||
const value = parseInt(match[1], 10)
|
|
||||||
const unit = match[2]
|
|
||||||
|
|
||||||
switch (unit) {
|
|
||||||
case 'd':
|
|
||||||
return value
|
|
||||||
case 'w':
|
|
||||||
return value * 7
|
|
||||||
case 'm':
|
|
||||||
return value * 30
|
|
||||||
case 'y':
|
|
||||||
return value * 365
|
|
||||||
default:
|
|
||||||
return 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateChange24h(history: any[]): number {
|
|
||||||
if (history.length < 2) return 0
|
|
||||||
|
|
||||||
const today = history[0]
|
|
||||||
const yesterday = history[1]
|
|
||||||
|
|
||||||
return Math.round((today.coveragePercentage - yesterday.coveragePercentage) * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToCSV(records: any[]): string {
|
|
||||||
const headers = ['Date', 'Coverage %', 'ASNs Sampled', 'ASNs with ASPA']
|
|
||||||
const rows = records.map((r) => [
|
|
||||||
r.sampleDate.toISOString().split('T')[0],
|
|
||||||
r.coveragePercentage,
|
|
||||||
r.totalASNsSampled,
|
|
||||||
r.ASNsWithASPA,
|
|
||||||
])
|
|
||||||
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(','),
|
|
||||||
...rows.map((row) => row.join(',')),
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
return csvContent
|
|
||||||
}
|
|
||||||
@ -1,264 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
|
||||||
import { getDatabase } from '../lib/db'
|
|
||||||
import { HijackAlertsDatabaseClient } from '../features/hijack-alerts/db-client'
|
|
||||||
import { WebhookClient } from '../features/hijack-alerts/webhook-client'
|
|
||||||
import { checkForHijacks } from '../features/hijack-alerts/detector'
|
|
||||||
import crypto from 'crypto'
|
|
||||||
|
|
||||||
const db = new HijackAlertsDatabaseClient(getDatabase())
|
|
||||||
const webhookClient = new WebhookClient()
|
|
||||||
|
|
||||||
function generateSecretKey(): string {
|
|
||||||
return 'sk_' + crypto.randomBytes(32).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RegisterWebhookRequest {
|
|
||||||
endpoint_url: string
|
|
||||||
timeout_ms?: number
|
|
||||||
max_retries?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateHijackRequest {
|
|
||||||
asn: number
|
|
||||||
prefix: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hijackAlertsRoutes(fastify: FastifyInstance): Promise<void> {
|
|
||||||
// Register webhook subscription
|
|
||||||
fastify.post<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>(
|
|
||||||
'/webhooks',
|
|
||||||
async (request: FastifyRequest<{ Querystring: { asn: string }; Body: RegisterWebhookRequest }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const asn = parseInt(request.query.asn, 10)
|
|
||||||
const body = request.body as RegisterWebhookRequest
|
|
||||||
|
|
||||||
if (isNaN(asn)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!body.endpoint_url) {
|
|
||||||
return reply.status(400).send({ error: 'endpoint_url is required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretKey = generateSecretKey()
|
|
||||||
const webhook = await db.createWebhookSubscription(
|
|
||||||
{
|
|
||||||
asn,
|
|
||||||
endpoint_url: body.endpoint_url,
|
|
||||||
timeout_ms: body.timeout_ms,
|
|
||||||
max_retries: body.max_retries,
|
|
||||||
},
|
|
||||||
secretKey
|
|
||||||
)
|
|
||||||
|
|
||||||
return reply.status(201).send({
|
|
||||||
id: webhook.id,
|
|
||||||
asn: webhook.asn,
|
|
||||||
endpoint_url: webhook.endpoint_url,
|
|
||||||
secret_key: secretKey,
|
|
||||||
created_at: webhook.created_at,
|
|
||||||
active: webhook.active,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error registering webhook:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to register webhook' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// List webhooks for ASN
|
|
||||||
fastify.get<{ Querystring: { asn: string } }>(
|
|
||||||
'/webhooks',
|
|
||||||
async (request: FastifyRequest<{ Querystring: { asn: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const asn = parseInt(request.query.asn, 10)
|
|
||||||
|
|
||||||
if (isNaN(asn)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhooks = await db.getWebhooksByAsn(asn)
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
asn,
|
|
||||||
webhooks: webhooks.map((w) => ({
|
|
||||||
id: w.id,
|
|
||||||
endpoint_url: w.endpoint_url,
|
|
||||||
active: w.active,
|
|
||||||
failure_count: w.failure_count,
|
|
||||||
last_triggered_at: w.last_triggered_at,
|
|
||||||
max_retries: w.max_retries,
|
|
||||||
timeout_ms: w.timeout_ms,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error listing webhooks:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to list webhooks' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delete webhook subscription
|
|
||||||
fastify.delete<{ Params: { id: string } }>(
|
|
||||||
'/webhooks/:id',
|
|
||||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const webhookId = parseInt(request.params.id, 10)
|
|
||||||
|
|
||||||
if (isNaN(webhookId)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid webhook ID' })
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.deleteWebhookSubscription(webhookId)
|
|
||||||
|
|
||||||
return reply.send({ deleted: true, id: webhookId })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error deleting webhook:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to delete webhook' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test webhook delivery
|
|
||||||
fastify.post<{ Params: { id: string } }>(
|
|
||||||
'/webhooks/:id/test',
|
|
||||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const webhookId = parseInt(request.params.id, 10)
|
|
||||||
|
|
||||||
if (isNaN(webhookId)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid webhook ID' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhook = await db.getWebhookSubscription(webhookId)
|
|
||||||
|
|
||||||
if (!webhook) {
|
|
||||||
return reply.status(404).send({ error: 'Webhook not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const testEvent = {
|
|
||||||
id: 0,
|
|
||||||
asn: webhook.asn,
|
|
||||||
prefix: '0.0.0.0/0',
|
|
||||||
detected_at: new Date(),
|
|
||||||
expected_asn: webhook.asn,
|
|
||||||
detected_asns: [webhook.asn],
|
|
||||||
hijack_type: 'HIJACK' as const,
|
|
||||||
severity: 'HIGH' as const,
|
|
||||||
description: 'Test event from PeerCortex',
|
|
||||||
details: { source: 'api-test', timestamp: new Date().toISOString() },
|
|
||||||
resolved: false,
|
|
||||||
resolved_at: null,
|
|
||||||
created_at: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await webhookClient.sendWebhook(
|
|
||||||
testEvent,
|
|
||||||
webhook.endpoint_url,
|
|
||||||
webhook.secret_key,
|
|
||||||
webhook.timeout_ms
|
|
||||||
)
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
success: result.success,
|
|
||||||
status: result.status,
|
|
||||||
error: result.error ?? null,
|
|
||||||
response_time_ms: result.response_time_ms,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error testing webhook:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to test webhook' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// List hijack events for ASN
|
|
||||||
fastify.get<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>(
|
|
||||||
'/hijacks',
|
|
||||||
async (request: FastifyRequest<{ Querystring: { asn: string; limit?: string; offset?: string; resolved?: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const asn = parseInt(request.query.asn, 10)
|
|
||||||
const limit = parseInt(request.query.limit ?? '50', 10)
|
|
||||||
const offset = parseInt(request.query.offset ?? '0', 10)
|
|
||||||
const resolved = request.query.resolved ? request.query.resolved === 'true' : undefined
|
|
||||||
|
|
||||||
if (isNaN(asn)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db.getHijacksByAsn(asn, Math.min(limit, 500), offset, resolved)
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
asn,
|
|
||||||
total: result.total,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
events: result.events.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
prefix: e.prefix,
|
|
||||||
hijack_type: e.hijack_type,
|
|
||||||
severity: e.severity,
|
|
||||||
detected_at: e.detected_at,
|
|
||||||
resolved: e.resolved,
|
|
||||||
resolved_at: e.resolved_at,
|
|
||||||
description: e.description,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error listing hijacks:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to list hijacks' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manually trigger hijack detection
|
|
||||||
fastify.post<{ Body: CreateHijackRequest }>(
|
|
||||||
'/hijacks/detect',
|
|
||||||
async (request: FastifyRequest<{ Body: CreateHijackRequest }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const body = request.body as CreateHijackRequest
|
|
||||||
|
|
||||||
if (!body.asn || !body.prefix) {
|
|
||||||
return reply.status(400).send({ error: 'asn and prefix are required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await checkForHijacks(`${body.asn}:${body.prefix}`, db)
|
|
||||||
|
|
||||||
if (results[0]?.detected && results[0]?.event) {
|
|
||||||
const hijack = await db.insertHijackEvent(results[0].event)
|
|
||||||
return reply.status(201).send({ detected: true, event: hijack })
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.send({ detected: false, reason: results[0]?.reason ?? 'No hijack detected' })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error detecting hijacks:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to detect hijacks' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Resolve hijack
|
|
||||||
fastify.post<{ Params: { id: string }; Body: { resolution_notes: string } }>(
|
|
||||||
'/hijacks/:id/resolve',
|
|
||||||
async (request: FastifyRequest<{ Params: { id: string }; Body: { resolution_notes: string } }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const eventId = parseInt(request.params.id, 10)
|
|
||||||
|
|
||||||
if (isNaN(eventId)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid event ID' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const hijack = await db.resolveHijack(eventId, request.body.resolution_notes)
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
id: hijack.id,
|
|
||||||
resolved: hijack.resolved,
|
|
||||||
resolved_at: hijack.resolved_at,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Routes] Error resolving hijack:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to resolve hijack' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
|
|
||||||
import { getDatabase } from '../lib/db'
|
|
||||||
import { PDFExportDatabaseClient } from '../features/pdf-export/db-client'
|
|
||||||
import { PDFRenderer, renderTemplate } from '../features/pdf-export/renderer'
|
|
||||||
import { PDFCacheManager } from '../features/pdf-export/cache-manager'
|
|
||||||
import * as fs from 'fs/promises'
|
|
||||||
import * as path from 'path'
|
|
||||||
import type { PDFTemplateData } from '../features/pdf-export/types'
|
|
||||||
|
|
||||||
const db = new PDFExportDatabaseClient(getDatabase())
|
|
||||||
const renderer = new PDFRenderer()
|
|
||||||
const cache = new PDFCacheManager()
|
|
||||||
|
|
||||||
interface ExportPDFQuery {
|
|
||||||
asn: string
|
|
||||||
format?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTemplate(format: string): Promise<string> {
|
|
||||||
const templatePath = path.join(
|
|
||||||
__dirname,
|
|
||||||
'../features/pdf-export/templates',
|
|
||||||
`${format}-template.html`
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fs.readFile(templatePath, 'utf-8')
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Template not found: ${format}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNetworkData(asn: number): Promise<PDFTemplateData> {
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
return {
|
|
||||||
asn,
|
|
||||||
network_name: `AS${asn} Network`,
|
|
||||||
generated_at: now.toISOString(),
|
|
||||||
health_score: {
|
|
||||||
overall: 78,
|
|
||||||
aspa: 65,
|
|
||||||
rpki: 85,
|
|
||||||
bgp_stability: 80,
|
|
||||||
peering_health: 75,
|
|
||||||
},
|
|
||||||
aspa: {
|
|
||||||
adoption_status: 'in_progress',
|
|
||||||
provider_verification: 45,
|
|
||||||
readiness_score: 60,
|
|
||||||
},
|
|
||||||
peering: {
|
|
||||||
ixp_connections: 12,
|
|
||||||
peer_count: 42,
|
|
||||||
open_peers: 28,
|
|
||||||
route_exports: 1250,
|
|
||||||
},
|
|
||||||
threats: {
|
|
||||||
recent_hijacks: 0,
|
|
||||||
anomalies_detected: 2,
|
|
||||||
rpki_invalids: 3,
|
|
||||||
moas_events: 1,
|
|
||||||
},
|
|
||||||
recommendations: [
|
|
||||||
'Implement RPKI ROV for route validation',
|
|
||||||
'Increase ASPA adoption to reduce path spoofing risk',
|
|
||||||
'Review BGP community tagging practices',
|
|
||||||
'Monitor anomalies for potential routing issues',
|
|
||||||
'Expand IXP presence for redundancy',
|
|
||||||
],
|
|
||||||
data_sources: [
|
|
||||||
'RIPE RIS Route Collectors',
|
|
||||||
'RouteViews BGP Archive',
|
|
||||||
'RPKI Repository Objects',
|
|
||||||
'PeeringDB Network Database',
|
|
||||||
'WHOIS RDAP Queries',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pdfExportRoutes(fastify: FastifyInstance): Promise<void> {
|
|
||||||
fastify.get<{ Querystring: ExportPDFQuery }>(
|
|
||||||
'/export/pdf',
|
|
||||||
async (request: FastifyRequest<{ Querystring: ExportPDFQuery }>, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const asn = parseInt(request.query.asn, 10)
|
|
||||||
const format = (request.query.format ?? 'report') as string
|
|
||||||
|
|
||||||
if (isNaN(asn)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid ASN' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['report', 'executive', 'technical'].includes(format)) {
|
|
||||||
return reply.status(400).send({ error: 'Invalid format' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateStr = new Date().toISOString().split('T')[0]
|
|
||||||
const cacheKey = cache.generateKey(asn, format, dateStr)
|
|
||||||
const cachedEntry = cache.get(cacheKey)
|
|
||||||
|
|
||||||
if (cachedEntry) {
|
|
||||||
console.log(`[PDF Export] Cache hit for AS${asn} format=${format}`)
|
|
||||||
return reply
|
|
||||||
.header('Content-Type', 'application/pdf')
|
|
||||||
.header('Cache-Control', 'public, max-age=300')
|
|
||||||
.header('X-Cache-Hit', 'true')
|
|
||||||
.send(cachedEntry.pdfBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
await renderer.initialize()
|
|
||||||
|
|
||||||
const template = await loadTemplate(format)
|
|
||||||
const data = await getNetworkData(asn)
|
|
||||||
const html = renderTemplate(template, data)
|
|
||||||
|
|
||||||
const pdfBuffer = await renderer.renderPDF(html, 30000)
|
|
||||||
|
|
||||||
cache.set(cacheKey, pdfBuffer)
|
|
||||||
|
|
||||||
await db.recordPDFGeneration(
|
|
||||||
`AS${asn}`,
|
|
||||||
format,
|
|
||||||
require('crypto').createHash('sha256').update(pdfBuffer).digest('hex'),
|
|
||||||
pdfBuffer.length,
|
|
||||||
{ format, generated_at: new Date().toISOString() }
|
|
||||||
)
|
|
||||||
|
|
||||||
return reply
|
|
||||||
.header('Content-Type', 'application/pdf')
|
|
||||||
.header('Cache-Control', 'public, max-age=300')
|
|
||||||
.header('X-Cache-Hit', 'false')
|
|
||||||
.send(pdfBuffer)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[PDF Export] Error generating PDF:', error)
|
|
||||||
return reply.status(500).send({
|
|
||||||
error: 'Failed to generate PDF',
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get('/export/pdf/stats', async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
try {
|
|
||||||
const stats = await db.getPDFStats()
|
|
||||||
const cacheStats = cache.getStats()
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
database: stats,
|
|
||||||
cache: cacheStats,
|
|
||||||
ttl_ms: 5 * 60 * 1000,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[PDF Export] Error fetching stats:', error)
|
|
||||||
return reply.status(500).send({ error: 'Failed to fetch stats' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user