feat(bio-rd): add local RIB integration via bio-rd gRPC client

Adds real-time local BGP RIB data as a complement to external APIs
(RIPE Stat, bgproutes.io) which have rate limits and 15min+ delays.

- bio-rd-client.js: gRPC client for bio-routing/bio-rd RIS service
  - LPM, Get, GetLonger, GetRouters, DumpRIB, ObserveRIB methods
  - IPv4/IPv6 encoding as uint64 pair (bio.net format)
  - Full BGP path decode: AS paths, communities, large communities
  - Graceful fallback if RIS unavailable (null/empty returns)
- protos/: bio-rd proto definitions (ris, bgp, session, route, net)
- server.js: three new endpoints + WebSocket stream
  - GET /api/rib/prefix — LPM + more-specifics via GetLonger
  - GET /api/rib/routers — list BMP-monitored routers
  - GET /api/rib/dump — full RIB dump with ASN filter + limit
  - WS /ws/rib — live ObserveRIB stream (add/withdraw events)
- package.json: @grpc/grpc-js + @grpc/proto-loader dependencies
This commit is contained in:
Rene Fichtmueller 2026-04-05 11:44:50 +02:00
parent f1fe96132f
commit 344ee15338
9 changed files with 1172 additions and 9 deletions

448
bio-rd-client.js Normal file
View File

@ -0,0 +1,448 @@
'use strict';
/**
* bio-rd gRPC client wrapper for PeerCortex.
*
* Wraps the RoutingInformationService (RIS) from bio-rd.
* proto package: bio.ris (cmd/ris/api/ris.proto)
* proto package: bio.net (net/api/net.proto)
* proto package: bio.route (route/api/route.proto)
*
* Usage:
* const { createRisClient } = require('./bio-rd-client');
* const ris = createRisClient('localhost', 4321);
* const routes = await ris.dumpRib('router1', '0:0', 0);
*/
const path = require('path');
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const PROTO_DIR = path.join(__dirname, 'protos');
const RIS_PROTO = path.join(PROTO_DIR, 'cmd/ris/api/ris.proto');
const LOADER_OPTIONS = {
keepCase: true,
longs: String, // uint64 fields come back as strings (avoids JS BigInt issues)
enums: String, // enum values come back as their string names
defaults: true,
oneofs: true,
includeDirs: [PROTO_DIR],
};
const HIDDEN_REASON_MAP = {
HiddenReasonNone: null,
HiddenReasonNextHopUnreachable: 'next-hop-unreachable',
HiddenReasonFilteredByPolicy: 'filtered-by-policy',
HiddenReasonASLoop: 'as-loop',
HiddenReasonOurOriginatorID: 'our-originator-id',
HiddenReasonClusterLoop: 'cluster-loop',
HiddenReasonOTCMismatch: 'otc-mismatch',
};
const ORIGIN_MAP = { 0: 'IGP', 1: 'EGP', 2: 'Incomplete' };
// ─── IP helpers ──────────────────────────────────────────────────────────────
/**
* Convert a dotted-decimal or colon-hex IP string to the bio.net.IP proto object.
* uint64 fields are passed as strings (matching longs:'String' loader option).
*/
function ipToProto(ipString) {
if (ipString.includes(':')) {
return _ipv6ToProto(ipString);
}
return _ipv4ToProto(ipString);
}
function _ipv4ToProto(ipString) {
const parts = ipString.split('.').map(Number);
if (parts.length !== 4 || parts.some(isNaN)) {
throw new Error(`Invalid IPv4 address: ${ipString}`);
}
const val = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
return { higher: '0', lower: String(val), version: 'IPv4' };
}
function _ipv6ToProto(ipString) {
// Expand :: notation and parse into two uint64 halves
const expanded = _expandIPv6(ipString);
const groups = expanded.split(':').map(g => parseInt(g, 16));
const higher = BigInt(groups[0]) << 48n
| BigInt(groups[1]) << 32n
| BigInt(groups[2]) << 16n
| BigInt(groups[3]);
const lower = BigInt(groups[4]) << 48n
| BigInt(groups[5]) << 32n
| BigInt(groups[6]) << 16n
| BigInt(groups[7]);
return { higher: String(higher), lower: String(lower), version: 'IPv6' };
}
function _expandIPv6(addr) {
if (addr.includes('::')) {
const [left, right] = addr.split('::');
const leftGroups = left ? left.split(':') : [];
const rightGroups = right ? right.split(':') : [];
const missing = 8 - leftGroups.length - rightGroups.length;
const mid = Array(missing).fill('0');
return [...leftGroups, ...mid, ...rightGroups].join(':');
}
return addr;
}
/**
* Convert a bio.net.IP proto object back to a human-readable IP string.
*/
function protoToIp(protoIp) {
if (!protoIp) return null;
const version = protoIp.version || 'IPv4';
const lower = BigInt(protoIp.lower || '0');
const higher = BigInt(protoIp.higher || '0');
if (version === 'IPv4' || version === 0) {
const n = Number(lower);
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
}
// IPv6: reassemble 8 groups of 16 bits from two uint64 values
const mask16 = 0xffffn;
const groups = [
(higher >> 48n) & mask16,
(higher >> 32n) & mask16,
(higher >> 16n) & mask16,
higher & mask16,
(lower >> 48n) & mask16,
(lower >> 32n) & mask16,
(lower >> 16n) & mask16,
lower & mask16,
];
return groups.map(g => g.toString(16)).join(':');
}
/**
* Convert a CIDR string to a bio.net.Prefix proto object.
*/
function prefixToProto(cidr) {
const [ipStr, lenStr] = cidr.split('/');
const length = lenStr !== undefined ? parseInt(lenStr, 10) : (ipStr.includes(':') ? 128 : 32);
return { address: ipToProto(ipStr), length };
}
// ─── Route decoding ───────────────────────────────────────────────────────────
/**
* Decode a bio.route.Route proto object to a clean JS object.
* Handles both DumpRIBReply.route and direct Route messages.
*/
function decodeRoute(risRoute) {
if (!risRoute) return null;
// DumpRIBReply wraps the route in a .route field
const route = risRoute.route || risRoute;
if (!route || !route.pfx) return null;
const pfxAddr = protoToIp(route.pfx.address);
const pfxLen = route.pfx.length || 0;
const prefix = `${pfxAddr}/${pfxLen}`;
const paths = (route.paths || []).map(_decodePath);
// Flatten: return first path's attributes at top level, paths array for multi-path
const first = paths[0] || {};
return {
prefix,
nextHop: first.nextHop || null,
asPaths: first.asPaths || [],
communities: first.communities || [],
largeCommunities: first.largeCommunities || [],
localPref: first.localPref || 0,
med: first.med || 0,
origin: first.origin || null,
ebgp: first.ebgp || false,
hidden: first.hidden || false,
hiddenReason: first.hiddenReason || null,
paths,
};
}
function _decodePath(path) {
const hiddenReasonStr = typeof path.hidden_reason === 'string'
? path.hidden_reason
: Object.keys(HIDDEN_REASON_MAP)[path.hidden_reason] || 'HiddenReasonNone';
const hidden = hiddenReasonStr !== 'HiddenReasonNone';
const hiddenReason = HIDDEN_REASON_MAP[hiddenReasonStr] || null;
if (path.type === 'BGP' || path.type === 1) {
return _decodeBgpPath(path.bgp_path, hidden, hiddenReason);
}
if (path.type === 'Static' || path.type === 0) {
return _decodeStaticPath(path.static_path, hidden, hiddenReason);
}
return { nextHop: null, asPaths: [], communities: [], largeCommunities: [],
localPref: 0, med: 0, origin: null, ebgp: false, hidden, hiddenReason };
}
function _decodeBgpPath(bgp, hidden, hiddenReason) {
if (!bgp) return { nextHop: null, asPaths: [], communities: [], largeCommunities: [],
localPref: 0, med: 0, origin: null, ebgp: false, hidden, hiddenReason };
const asPaths = (bgp.as_path || []).flatMap(seg => seg.asns || []).map(Number);
const communities = (bgp.communities || []).map(c => {
const n = Number(c);
return `${(n >> 16) & 0xffff}:${n & 0xffff}`;
});
const largeCommunities = (bgp.large_communities || []).map(lc =>
`${lc.global_administrator}:${lc.data_part1}:${lc.data_part2}`
);
return {
nextHop: protoToIp(bgp.next_hop),
asPaths,
communities,
largeCommunities,
localPref: Number(bgp.local_pref || 0),
med: Number(bgp.med || 0),
origin: ORIGIN_MAP[bgp.origin] || 'Unknown',
ebgp: bgp.ebgp || false,
hidden,
hiddenReason,
};
}
function _decodeStaticPath(staticPath, hidden, hiddenReason) {
return {
nextHop: staticPath ? protoToIp(staticPath.next_hop) : null,
asPaths: [], communities: [], largeCommunities: [],
localPref: 0, med: 0, origin: 'Static', ebgp: false,
hidden, hiddenReason,
};
}
// ─── Client factory ───────────────────────────────────────────────────────────
/**
* Load the RIS proto definition (cached after first call).
*/
let _packageDef = null;
function _loadPackageDef() {
if (_packageDef) return _packageDef;
_packageDef = protoLoader.loadSync(RIS_PROTO, LOADER_OPTIONS);
return _packageDef;
}
/**
* Create a RIS gRPC client.
* Returns null if the package definition cannot be loaded.
*/
function createRisClient(host = 'localhost', port = 4321) {
let RisService;
try {
const packageDef = _loadPackageDef();
const proto = grpc.loadPackageDefinition(packageDef);
RisService = proto.bio.ris.RoutingInformationService;
} catch (err) {
console.error('[bio-rd] Failed to load proto definition:', err.message);
return null;
}
const address = `${host}:${port}`;
const channelOptions = {
'grpc.connect_timeout_ms': 5000,
'grpc.initial_reconnect_backoff_ms': 1000,
};
const channel = new grpc.Channel(
address,
grpc.credentials.createInsecure(),
channelOptions
);
const client = new RisService(address, grpc.credentials.createInsecure(), channelOptions);
// ── Unary helper ────────────────────────────────────────────────────────────
function _unary(method, request) {
return new Promise((resolve, reject) => {
const deadline = new Date(Date.now() + 5000);
client[method](request, { deadline }, (err, response) => {
if (err) return reject(err);
resolve(response);
});
});
}
// ── Stream collector ────────────────────────────────────────────────────────
function _collectStream(method, request) {
return new Promise((resolve, reject) => {
const results = [];
const deadline = new Date(Date.now() + 30000);
const call = client[method](request, { deadline });
call.on('data', chunk => results.push(chunk));
call.on('error', reject);
call.on('end', () => resolve(results));
});
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Longest Prefix Match for a given prefix.
* Returns the best matching route or null.
*/
async function lpm(router, prefix) {
try {
const response = await _unary('lPM', {
router,
vrf: '0:0',
pfx: prefixToProto(prefix),
});
const routes = (response.routes || []).map(decodeRoute).filter(Boolean);
return routes[0] || null;
} catch (err) {
console.warn(`[bio-rd] lpm(${router}, ${prefix}) failed:`, err.message);
return null;
}
}
/**
* Exact prefix lookup.
* Returns the matching route or null.
*/
async function get(router, prefix) {
try {
const response = await _unary('get', {
router,
vrf: '0:0',
pfx: prefixToProto(prefix),
});
const routes = (response.routes || []).map(decodeRoute).filter(Boolean);
return routes[0] || null;
} catch (err) {
console.warn(`[bio-rd] get(${router}, ${prefix}) failed:`, err.message);
return null;
}
}
/**
* Get all more-specific prefixes covered by the given prefix.
* Returns an array of decoded routes.
*/
async function getLonger(router, prefix) {
try {
const response = await _unary('getLonger', {
router,
vrf: '0:0',
pfx: prefixToProto(prefix),
});
return (response.routes || []).map(decodeRoute).filter(Boolean);
} catch (err) {
console.warn(`[bio-rd] getLonger(${router}, ${prefix}) failed:`, err.message);
return [];
}
}
/**
* Get all routers known to this RIS instance.
* Returns an array of { name, address } objects.
*/
async function getRouters() {
try {
const response = await _unary('getRouters', {});
return (response.routers || []).map(r => ({
name: r.sys_name || '',
address: r.address || '',
}));
} catch (err) {
console.warn('[bio-rd] getRouters() failed:', err.message);
return [];
}
}
/**
* Dump the full RIB for a router/VRF, optionally filtered by origin ASN.
* Collects the entire stream and returns an array of decoded routes.
*
* @param {string} router - Router identifier (e.g. "router1")
* @param {string} vrfName - VRF name (e.g. "0:0" for default)
* @param {number} originAsn - Filter by originating AS (0 = no filter)
*/
async function dumpRib(router, vrfName = '0:0', originAsn = 0) {
try {
const request = {
router,
vrf: vrfName,
afisafi: 'IPv4Unicast',
filter: { originating_asn: originAsn || 0 },
};
const chunks = await _collectStream('dumpRIB', request);
return chunks.map(decodeRoute).filter(Boolean);
} catch (err) {
console.warn(`[bio-rd] dumpRib(${router}) failed:`, err.message);
return [];
}
}
/**
* Observe real-time RIB updates for a router/VRF.
*
* @param {string} router - Router identifier
* @param {string} vrfName - VRF name (e.g. "0:0")
* @param {function} onUpdate - Called with { type: 'add'|'withdraw', route }
* @param {function} onError - Called with the error on stream failure
* @returns {function} cancel - Call to stop the observation
*/
function observeRib(router, vrfName = '0:0', onUpdate, onError) {
const request = {
router,
vrf: vrfName,
afisafi: 'IPv4Unicast',
};
let call;
try {
call = client.observeRIB(request);
} catch (err) {
if (onError) onError(err);
return () => {};
}
call.on('data', (update) => {
if (!update || !update.route) return;
const type = update.advertisement ? 'add' : 'withdraw';
const route = decodeRoute(update.route);
if (route && onUpdate) onUpdate({ type, route });
});
call.on('error', (err) => {
console.warn(`[bio-rd] observeRib(${router}) stream error:`, err.message);
if (onError) onError(err);
});
call.on('end', () => {
console.log(`[bio-rd] observeRib(${router}) stream ended`);
});
return () => {
try { call.cancel(); } catch (_) {}
};
}
// ── Cleanup ─────────────────────────────────────────────────────────────────
function close() {
try { channel.close(); } catch (_) {}
try { client.close(); } catch (_) {}
}
return { lpm, get, getLonger, getRouters, dumpRib, observeRib, close };
}
module.exports = {
createRisClient,
ipToProto,
prefixToProto,
protoToIp,
decodeRoute,
};

329
package-lock.json generated
View File

@ -1,14 +1,16 @@
{
"name": "peercortex",
"version": "0.1.0",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peercortex",
"version": "0.1.0",
"version": "0.6.5",
"license": "MIT",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"better-sqlite3": "^11.7.0",
"cheerio": "^1.0.0",
@ -774,6 +776,37 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.3",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
@ -905,6 +938,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
@ -956,6 +999,70 @@
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
@ -1373,7 +1480,6 @@
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -1844,7 +1950,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -2167,11 +2272,82 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -2184,7 +2360,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@ -2580,6 +2755,15 @@
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -3161,6 +3345,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -3524,7 +3717,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -3713,6 +3905,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3720,6 +3918,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -4315,6 +4519,30 @@
"node": ">= 0.8.0"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -4450,6 +4678,15 @@
"node": ">= 6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -5176,7 +5413,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@ -5972,6 +6208,83 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -63,6 +63,8 @@
"node": ">=20.0.0"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@modelcontextprotocol/sdk": "^1.12.0",
"better-sqlite3": "^11.7.0",
"cheerio": "^1.0.0",
@ -73,7 +75,6 @@
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
"@types/node-whois": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitest/coverage-v8": "^2.1.0",

View File

@ -0,0 +1,104 @@
syntax = "proto3";
package bio.ris;
import "net/api/net.proto";
import "route/api/route.proto";
option go_package = "github.com/bio-routing/bio-rd/cmd/ris/api";
service RoutingInformationService {
rpc LPM(LPMRequest) returns (LPMResponse) {};
rpc Get(GetRequest) returns (GetResponse) {};
rpc GetRouters(GetRoutersRequest) returns (GetRoutersResponse) {};
rpc GetLonger(GetLongerRequest) returns (GetLongerResponse) {};
rpc ObserveRIB(ObserveRIBRequest) returns (stream RIBUpdate);
rpc DumpRIB(DumpRIBRequest) returns (stream DumpRIBReply);
}
message LPMRequest {
string router = 1;
uint64 vrf_id = 2;
string vrf = 4;
bio.net.Prefix pfx = 3;
}
message LPMResponse {
repeated bio.route.Route routes = 1;
}
message GetRequest {
string router = 1;
uint64 vrf_id = 2;
string vrf = 4;
bio.net.Prefix pfx = 3;
}
message GetResponse {
repeated bio.route.Route routes = 1;
}
message GetLongerRequest {
string router = 1;
uint64 vrf_id = 2;
string vrf = 4;
bio.net.Prefix pfx = 3;
}
message GetLongerResponse {
repeated bio.route.Route routes = 1;
}
message ObserveRIBRequest {
string router = 1;
uint64 vrf_id = 2;
string vrf = 4;
enum AFISAFI {
IPv4Unicast = 0;
IPv6Unicast = 1;
}
AFISAFI afisafi = 3;
bool allow_unready_rib = 5;
}
message RIBFilter {
uint32 originating_asn = 1;
uint32 min_length = 2;
uint32 max_length = 3;
}
message RIBUpdate {
bool advertisement = 1;
bool is_initial_dump = 3;
bool end_of_rib = 4;
bio.route.Route route = 2;
}
message DumpRIBRequest {
string router = 1;
uint64 vrf_id = 2;
string vrf = 4;
enum AFISAFI {
IPv4Unicast = 0;
IPv6Unicast = 1;
}
AFISAFI afisafi = 3;
RIBFilter filter = 5;
}
message DumpRIBReply {
bio.route.Route route = 1;
}
message GetRoutersRequest {
}
message Router {
string sys_name = 1;
repeated uint64 vrf_ids = 2;
string address = 3;
}
message GetRoutersResponse {
repeated Router routers = 1;
}

19
protos/net/api/net.proto Normal file
View File

@ -0,0 +1,19 @@
syntax = "proto3";
package bio.net;
option go_package = "github.com/bio-routing/bio-rd/net/api";
message Prefix {
IP address = 1;
uint32 length = 2;
}
message IP {
uint64 higher = 1;
uint64 lower = 2;
enum Version {
IPv4 = 0;
IPv6 = 1;
}
Version version = 3;
}

View File

@ -0,0 +1,34 @@
syntax = "proto3";
package bio.bgp;
import "net/api/net.proto";
import "route/api/route.proto";
import "protocols/bgp/api/session.proto";
option go_package = "github.com/bio-routing/bio-rd/protocols/bgp/api";
message ListSessionsRequest {
SessionFilter filter = 1;
}
message SessionFilter {
bio.net.IP neighbor_ip = 1;
string vrf_name = 2;
}
message ListSessionsResponse {
repeated Session sessions = 1;
}
message DumpRIBRequest {
bio.net.IP peer = 1;
uint32 afi = 2;
uint32 safi = 3;
string vrf_name = 4;
}
service BgpService {
rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse) {}
rpc DumpRIBIn(DumpRIBRequest) returns (stream bio.route.Route) {}
rpc DumpRIBOut(DumpRIBRequest) returns (stream bio.route.Route) {}
}

View File

@ -0,0 +1,35 @@
syntax = "proto3";
package bio.bgp;
import "net/api/net.proto";
option go_package = "github.com/bio-routing/bio-rd/protocols/bgp/api";
message Session {
bio.net.IP local_address = 1;
bio.net.IP neighbor_address = 2;
uint32 local_asn = 3;
uint32 peer_asn = 4;
enum State {
Disabled = 0;
Idle = 1;
Connect = 2;
Active = 3;
OpenSent = 4;
OpenConfirmed = 5;
Established = 6;
}
State status = 5;
SessionStats stats = 6;
uint64 established_since = 7;
string description = 8;
}
message SessionStats {
uint64 messages_in = 1;
uint64 messages_out = 2;
uint64 flaps = 3;
uint64 routes_received = 4;
uint64 routes_imported = 5;
uint64 routes_exported = 6;
}

View File

@ -0,0 +1,80 @@
syntax = "proto3";
package bio.route;
import "net/api/net.proto";
option go_package = "github.com/bio-routing/bio-rd/route/api";
message Route {
bio.net.Prefix pfx = 1;
repeated Path paths = 2;
}
message Path {
enum Type {
Static = 0;
BGP = 1;
}
enum HiddenReason {
HiddenReasonNone = 0;
HiddenReasonNextHopUnreachable = 1;
HiddenReasonFilteredByPolicy = 2;
HiddenReasonASLoop = 3;
HiddenReasonOurOriginatorID = 4;
HiddenReasonClusterLoop = 5;
HiddenReasonOTCMismatch = 6;
}
Type type = 1;
StaticPath static_path = 2;
BGPPath bgp_path = 3;
HiddenReason hidden_reason = 4;
uint32 time_learned = 5;
GRPPath grp_path = 6;
}
message StaticPath {
bio.net.IP next_hop = 1;
}
message GRPPath {
bio.net.IP next_hop = 1;
map<string,string> meta_data = 2;
}
message BGPPath {
uint32 path_identifier = 1;
bio.net.IP next_hop = 2;
uint32 local_pref = 3;
repeated ASPathSegment as_path = 4;
uint32 origin = 5;
uint32 med = 6;
bool ebgp = 7;
uint32 bgp_identifier = 8;
bio.net.IP source = 9;
repeated uint32 communities = 10;
repeated LargeCommunity large_communities = 11;
uint32 originator_id = 12;
repeated uint32 cluster_list = 13;
repeated UnknownPathAttribute unknown_attributes = 14;
bool bmp_post_policy = 15;
uint32 only_to_customer = 16;
}
message ASPathSegment {
bool as_sequence = 1;
repeated uint32 asns = 2;
}
message LargeCommunity {
uint32 global_administrator = 1;
uint32 data_part1 = 2;
uint32 data_part2 = 3;
}
message UnknownPathAttribute {
bool optional = 1;
bool transitive = 2;
bool partial = 3;
uint32 type_code = 4;
bytes value = 5;
}

129
server.js
View File

@ -24,6 +24,18 @@ try {
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
// bio-rd gRPC client (optional — graceful fallback if unavailable)
let risClient = null;
try {
const { createRisClient } = require('./bio-rd-client');
const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost';
const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321');
risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT);
console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`);
} catch (e) {
console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)');
}
const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || "";
const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api";
@ -4899,6 +4911,81 @@ ${html}
}
}
// ── bio-rd RIB: prefix lookup ──────────────────────────────────
if (reqPath === '/api/rib/prefix') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
const params = new URL(req.url, 'http://localhost').searchParams;
const prefix = params.get('prefix') || '';
const routerParam = params.get('router') || 'default';
if (!prefix) { res.writeHead(400); return res.end(JSON.stringify({ error: 'prefix required' })); }
try {
const t0 = Date.now();
const routers = await risClient.getRouters();
const routerName = (routerParam === 'default' && routers.length > 0) ? routers[0] : routerParam;
const [routes, longer] = await Promise.all([
risClient.lpm(routerName, prefix),
risClient.getLonger(routerName, prefix),
]);
res.writeHead(200);
return res.end(JSON.stringify({
prefix,
router: routerName,
routes: routes || [],
moreSpecifics: (longer || []).slice(0, 20),
source: 'bio-rd-local',
latencyMs: Date.now() - t0,
}));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
}
}
// ── bio-rd RIB: list routers ───────────────────────────────────
if (reqPath === '/api/rib/routers') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
try {
const routers = await risClient.getRouters();
res.writeHead(200);
return res.end(JSON.stringify({ routers: routers || [], source: 'bio-rd-local' }));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
}
}
// ── bio-rd RIB: dump ───────────────────────────────────────────
if (reqPath === '/api/rib/dump') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'no-store');
if (!risClient) { res.writeHead(503); return res.end(JSON.stringify({ error: 'bio-rd RIS not configured' })); }
const params = new URL(req.url, 'http://localhost').searchParams;
const router = params.get('router') || '';
const asnFilter = params.get('asn') ? parseInt(params.get('asn')) : undefined;
const limit = Math.min(parseInt(params.get('limit') || '100', 10), 1000);
if (!router) { res.writeHead(400); return res.end(JSON.stringify({ error: 'router required' })); }
try {
const t0 = Date.now();
const allRoutes = await risClient.dumpRib(router, 'default', asnFilter);
const routes = (allRoutes || []).slice(0, limit);
res.writeHead(200);
return res.end(JSON.stringify({
router,
routes,
total: (allRoutes || []).length,
source: 'bio-rd-local',
latencyMs: Date.now() - t0,
}));
} catch(e) {
res.writeHead(500); return res.end(JSON.stringify({ error: e.message }));
}
}
// 404
res.writeHead(404);
res.end(
@ -5076,6 +5163,48 @@ Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]
});
});
// ============================================================
// bio-rd RIB WebSocket — live route streaming on /ws/rib
// ============================================================
let WebSocketServer = null;
try { WebSocketServer = require('ws').Server; } catch(_e) {}
if (WebSocketServer) {
const ribWss = new WebSocketServer({ server, path: '/ws/rib' });
ribWss.on('connection', function(ws) {
let cancelStream = null;
ws.on('message', function(raw) {
try {
const msg = JSON.parse(raw);
if (msg.type === 'rib-subscribe') {
if (cancelStream) { cancelStream(); cancelStream = null; }
if (!risClient) {
ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' }));
return;
}
const router = msg.router || 'default';
cancelStream = risClient.observeRib(
router,
'default',
function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); },
function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); }
);
}
} catch(e) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message }));
}
});
ws.on('close', function() {
if (cancelStream) { cancelStream(); cancelStream = null; }
});
});
console.log('[bio-rd] RIB WebSocket server listening on /ws/rib');
} else {
console.log('[bio-rd] WebSocket server skipped (ws package not installed)');
}
// ============================================================
// Refresh timers — jittered to avoid thundering herd
// ============================================================