Compare commits

..

13 Commits

Author SHA1 Message Date
Rene Fichtmueller
d8e2897a79 fix: add missing header when CHANGELOG_PENDING.md pre-exists but is empty
The repo already had an empty CHANGELOG_PENDING.md from 2026-03-30, so the
FileNotFoundError branch that adds the header/marker never fired -- the
first automated entry landed with no header, just leading blank lines.
Treat an existing-but-blank file the same as a missing one. Also fixes up
the header on the entry already drafted by the previous run. [skip ci]
2026-07-18 07:13:21 +00:00
changelog-bot
c0df5ff557 chore(changelog): draft pending entries [skip ci] 2026-07-18 07:11:43 +00:00
Rene Fichtmueller
a4506de1c5 fix: tolerate 1-2ms setTimeout jitter in the Ollama-enrichment timeout test
All checks were successful
build-verify / build-verify (push) Successful in 18s
changelog-draft / changelog-draft (push) Successful in 4s
CI build-verify run #23 (commit 388c4f3) failed on a strict lower bound
(elapsed >= 5000) that measured 4999ms -- normal clock/timer jitter between
the two Date.now() reads, not a real regression in the 5s AbortController
timeout itself. Confirmed unrelated to that commit's actual change (a
route-glue removal). Loosened the floor to 4990ms.
2026-07-18 07:11:14 +00:00
Rene Fichtmueller
f72ce5f991 ci: add deterministic changelog-draft workflow
All checks were successful
build-verify / build-verify (push) Successful in 17s
changelog-draft / changelog-draft (push) Successful in 3s
Drafts CHANGELOG_PENDING.md entries from Conventional Commit subjects on
every push to main -- feat/fix/refactor/perf/security bucketed into
Added/Fixed/Changed, chore/ci/test/docs/style/merge skipped, anything
mentioning internal infra hostnames dropped. No LLM call, no network
access, purely regex over commit subjects already in the pushed range.
Never touches CHANGELOG.md -- still needs a human to fold an entry in.
2026-07-18 07:10:24 +00:00
Rene Fichtmueller
017b79709b docs: catch up CHANGELOG for v0.7.1 (first entry since April)
All checks were successful
build-verify / build-verify (push) Successful in 20s
Summarizes everything shipped since the v0.7.0 entry: the network.country
fix from this session, the RPKI/ASPA/bogon correctness fixes and route
reconnection work from 2026-07-15..17, and the server.js -> feature-module
refactor. package.json bumped 0.7.0 -> 0.7.1 to match.
2026-07-18 07:05:50 +00:00
Rene Fichtmueller
50dc4e6b6a fix: network.country was always empty -- both its data sources were dead stubs
All checks were successful
build-verify / build-verify (push) Successful in 25s
/api/lookup's deriveRirAndCountry() can only populate country from two
inputs: rirEntries (RIPE Stat rir-stats-country) and bgpHeData.country_code
(bgp.he.net). Both of their fetches in the Phase 1 Promise.all are
hardcoded to Promise.resolve(null) (see the timedFetch("RIPE Stat RIR", ...)
and timedFetch("bgp.he.net", ...) lines), so country has been unpopulated
for every single lookup regardless of ASN. rir stayed populated separately
via the RDAP port43/links fallback, which masked the issue -- rir looked
fine, country silently didn't.

Confirmed via today's rotating_audit.py run: 100/100 sampled ASNs flagged
country_empty.

Fix: fall back to PeeringDB's org-level country (pdbOrgState, loaded at
startup from /api/org, already used successfully by lia.js's coverage
endpoint for the same purpose) when the RIR-derived country is empty.
Does not touch the two dead stubs -- those look like deliberate stand-ins
for sources that were never wired up, out of scope here.

Verified live post-deploy: AS680 DE, AS847 US, AS1251 BR now populated
(previously all three were empty). AS4628 still empty -- its PeeringDB org
itself has no country set, a genuine upstream data gap, not something this
fallback can fix. npm test: 215/215 passing.
2026-07-18 07:03:06 +00:00
Rene Fichtmueller
388c4f3c73 refactor: remove unreachable hijack-alerts/pdf-export/aspa-adoption/hijack-subscribe route glue
Some checks failed
build-verify / build-verify (push) Failing after 15s
Traced 2026-07-17: none of these 4 Fastify route files were ever
reachable in production -- server/routes/proxy-stubs.js's EXACT_PATHS
never proxies their paths to this API server, so the old file-backed
server/routes/*.js handlers in server.js have been the only thing
actually serving /api/webhooks, /api/hijacks, /api/hijack-subscribe,
/api/export/pdf*, /aspa-adoption, /api/aspa-adoption-stats*, and
/api/ipv6-adoption-stats all along. Confirmed via a full-repo grep that
nothing else imports these 4 files.

Deliberately kept the underlying feature module trees intact
(src/features/{aspa-adoption,hijack-alerts,pdf-export}/*.ts + their 16
passing unit test files) -- unlike the thin route glue removed here,
those represent real, independently-tested implementation work (db
clients, samplers, forecasters, detectors, webhook delivery, PDF
rendering) that's worth keeping as-is for a future proper cutover
rather than deleting outright. Two of them are known incomplete even
if wired up (pdf-export's getNetworkData() is hardcoded mock data,
aspa-adoption's scheduler.ts is never imported anywhere), so "finish
and cut over" is real work, not just re-adding these 4 files.

Verified: full-repo grep found no other consumers, tsc --noEmit has no
new "cannot find module" errors, `npm test` still passes 215/215 (all
16 feature-module test files untouched), a clean `npm run build`
produces the expected dist/api/{index,server}.js with no compiled
output for the deleted routes.
2026-07-17 09:14:42 +02:00
Rene Fichtmueller
e2846dd8e8 fix: stop /api/health gating overall status on the vestigial local_db import pipeline
Traced local_db (bgp_routes/rpki_roas) 2026-07-17: two systemd import
timers on Erik have run "successfully" daily since creation (2026-04-28)
while doing zero real work -- bgp-importer's Route Views source URL is
invalid (parses 0 routes every run, the 36 rows are a one-time manual
seed), rpki-importer's feed URLs are both dead and it silently falls
back to 5 hardcoded fake ROAs re-upserted daily. The watchdog meant to
catch this has itself failed every run since creation (Postgres auth
error), so nothing ever alerted.

No user-facing endpoint reads from local_db -- only this health check
and the unused /api/bgp route do. Gating overall `status` on
bgp_routes/rpki_roas row counts meant /api/health reported "degraded"
permanently for a dependency nothing actually needs, which could mask
a real future degradation behind the noise. Still reports the raw
local_db stats (including its own `healthy` sub-field) for diagnostic
visibility; only removed it from the top-level status gate.

Verified live on Erik: status now reports "ok" when RPKI/ASPA freshness
is fine, instead of permanently "degraded".
2026-07-17 09:14:24 +02:00
Rene Fichtmueller
dcd08accd5 docs: update stale vitest-CI-failure comment now that it's confirmed fixed
All checks were successful
build-verify / build-verify (push) Successful in 15s
Root cause traced: package-lock.json was missing per-platform optional
dependency entries for Rollup/esbuild's Linux native binaries
(npm/cli#4828) since it had only ever been regenerated on darwin-arm64.
npm ci on the Linux runner silently skipped them, Vite's synchronous
require() at vitest startup threw before any test loaded. Fixed as a
side effect of the full package-lock.json regeneration in a0ad052.
Confirmed via Gitea Actions run history: failure before, 2 consecutive
successes after.
2026-07-17 08:47:34 +02:00
Rene Fichtmueller
4c286d7f2d fix: local Postgres RPKI validation query was broken (double CIDR suffix + wrong containment operator)
All checks were successful
build-verify / build-verify (push) Successful in 15s
validateRpki()'s covering-ROA query concatenated "/" + max_length onto
`prefix`, which is already a `cidr` column with its own mask -- produced
invalid literals like "1.1.1.0/24/24" and threw on every single lookup
(observed live: "invalid input syntax for type cidr"). Every RPKI
completeness check has been silently degrading to "unavailable" since
this landed.

Also switched << (strictly contained) to <<= (contained-or-equal) in
both this query and the anyRoa fallback -- a route announced at exactly
the ROA's own prefix length (the most common real-world case) was never
matched by << alone, so even a syntactically-valid version of this
query would have under-reported RPKI coverage.

Verified against production data on Erik: AS13335/1.1.1.0/24 now
correctly resolves to a real covering ROA instead of erroring.
2026-07-17 08:43:33 +02:00
Rene Fichtmueller
381db11a5c chore: checkpoint accumulated hijack-alerts + aspa-adoption-history from Erik
All checks were successful
build-verify / build-verify (push) Successful in 16s
hijack-alerts.json grew from 24549 to 40123 lines, aspa-adoption-history.json
updated with new daily snapshots since the last checkpoint (7df0fc2,
2026-07-16). Real operational data, not code.
2026-07-17 06:20:29 +00:00
Rene Fichtmueller
a0ad05223e chore: bump better-sqlite3 to 11.10.0, add canonical link + analytics script
Applies stash@{0} (WIP on d3611a8, previously untouched) — package.json
dependency bump plus two <head> additions to public/index.html for SEO
(canonical URL) and self-hosted Umami analytics.
2026-07-17 06:20:18 +00:00
Rene Fichtmueller
8d5f2fdd97 Merge branch 'refactor/server-js-split' into main
Some checks failed
build-verify / build-verify (push) Failing after 9s
Splits server.js (6838 lines) into server/*.js modules per the
800-line project limit. Behavior-preserving except 5 pre-existing
bugs fixed along the way (see PR #3 for details): compare's missing
fetchRPKIPerPrefix, webhooks' undefined `secret` reference, health's
DB-stats-failure closure bug, PeeringDB Authorization:undefined crash,
RESULT_CACHE_TTL export regression.

server.js: 6838 -> 578 lines. Verified via node --check, eslint
no-undef, and a 28-endpoint smoke-test diff at every step.

Closes gitea#3.
2026-07-17 08:16:57 +02:00
20 changed files with 36264 additions and 22734 deletions

105
.github/scripts/changelog-draft.py vendored Normal file
View 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()

View File

@ -80,18 +80,11 @@ jobs:
- name: vitest
run: npm test
# KNOWN OPEN ISSUE (2026-07-16): this step fails in 0-1s on this specific
# Gitea Actions runner for a reason not diagnosable from a local machine --
# tried forcing a small fixed forks pool (--pool=forks, min/maxForks) as a
# guess (common fix for CI-only vitest worker-pool crashes), made no
# difference. The actual test suite passes 215/215 reliably every way
# tested locally: plain `npm test`, with CI=true set, and after a fully
# fresh `rm -rf node_modules && npm ci`. The job's "Complete job" step
# also shows failure/canRerun:false, which may indicate a runner-level
# 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.
# 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
View 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

View File

@ -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
### Added

View File

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

View File

@ -859,12 +859,12 @@
},
{
"date": "2026-07-16",
"ts": 1784174057994,
"aspa_objects": 2382,
"roa_count": 971831,
"atlas_asns_total": 4775,
"atlas_asns_with_aspa": 504,
"coverage_pct_atlas": 10.55,
"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"
}

File diff suppressed because it is too large Load Diff

View File

@ -101,10 +101,17 @@ async function validateRpki(prefix, originAsn) {
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(
`SELECT * FROM rpki_roas
WHERE $1::cidr << (prefix || '/' || max_length)::cidr
WHERE $1::cidr <<= prefix
AND origin_asn = $2
AND expires > NOW()
LIMIT 10`,
@ -113,7 +120,7 @@ async function validateRpki(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`,
`SELECT 1 FROM rpki_roas WHERE $1::cidr <<= prefix AND expires > NOW() LIMIT 1`,
[prefix]
);

2028
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"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.",
"main": "dist/mcp-server/index.js",
"types": "dist/mcp-server/index.d.ts",
@ -67,7 +67,7 @@
"@grpc/proto-loader": "^0.8.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"axios": "^1.6.0",
"better-sqlite3": "^11.7.0",
"better-sqlite3": "^11.10.0",
"cheerio": "^1.0.0",
"fastify": "^5.8.5",
"joi": "^17.11.0",

File diff suppressed because it is too large Load Diff

View File

@ -110,6 +110,13 @@ const VOLATILE_PATHS = new Set([
"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) {

View File

@ -27,10 +27,17 @@ function handleHealth(req, res) {
// Query local DB stats (async, but return partial if needed)
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 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 = {
status,

View File

@ -14,6 +14,7 @@ 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) {
@ -177,7 +178,12 @@ async function handleLookup(req, res, url) {
downstreams = enriched.downstreams;
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;

View File

@ -1,8 +1,5 @@
import Fastify from 'fastify'
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 { irrAuditRoutes } from '../features/irr-audit/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 { changelogRoutes } from '../features/changelog/routes'
import { ribRoutes } from '../features/rib/routes'
import { hijackSubscribeRoutes } from '../features/hijack-subscribe/routes'
export async function createApiServer(_port: number = 3102) {
const fastify = Fastify({
@ -24,10 +20,12 @@ export async function createApiServer(_port: number = 3102) {
},
})
// Initialize routes
await fastify.register(hijackAlertsRoutes, { prefix: '/api' })
await fastify.register(pdfExportRoutes, { prefix: '/api' })
await fastify.register(aspaAdoptionRoutes, { prefix: '/api' })
// Initialize routes. hijack-alerts/pdf-export/aspa-adoption/hijack-subscribe
// were removed 2026-07-17: never reachable in production (server/routes/
// proxy-stubs.js's EXACT_PATHS never proxied their paths here), and the
// 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(irrAuditRoutes, { 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(changelogRoutes)
await fastify.register(ribRoutes, { prefix: '/api' })
await fastify.register(hijackSubscribeRoutes, { prefix: '/api' })
// Health check endpoint
fastify.get('/health', async () => {
@ -58,7 +55,7 @@ export async function createApiServer(_port: number = 3102) {
return {
name: 'PeerCortex API',
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',
}
})

View File

@ -210,8 +210,10 @@ describe('Hijack Detector', () => {
const elapsed = Date.now() - startTime
expect(result).toBe(baseDesc)
// AbortController timeout should trigger around 5-6 seconds
expect(elapsed).toBeGreaterThanOrEqual(5000)
// AbortController timeout should trigger around 5-6 seconds. A few ms
// 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)
}, { timeout: 10000 })

View File

@ -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 });
}
}
);
}

View File

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

View File

@ -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' })
}
}
)
}

View File

@ -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' })
}
})
}