diff --git a/bio-rd-client.js b/bio-rd-client.js new file mode 100644 index 0000000..4ad2ccf --- /dev/null +++ b/bio-rd-client.js @@ -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, +}; diff --git a/package-lock.json b/package-lock.json index b65f51f..5ff6c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8d57c5a..b0ab52c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/protos/cmd/ris/api/ris.proto b/protos/cmd/ris/api/ris.proto new file mode 100644 index 0000000..2d5a6fb --- /dev/null +++ b/protos/cmd/ris/api/ris.proto @@ -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; +} diff --git a/protos/net/api/net.proto b/protos/net/api/net.proto new file mode 100644 index 0000000..f9b0ff4 --- /dev/null +++ b/protos/net/api/net.proto @@ -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; +} diff --git a/protos/protocols/bgp/api/bgp.proto b/protos/protocols/bgp/api/bgp.proto new file mode 100644 index 0000000..3985792 --- /dev/null +++ b/protos/protocols/bgp/api/bgp.proto @@ -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) {} +} diff --git a/protos/protocols/bgp/api/session.proto b/protos/protocols/bgp/api/session.proto new file mode 100644 index 0000000..ed1b9b6 --- /dev/null +++ b/protos/protocols/bgp/api/session.proto @@ -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; +} diff --git a/protos/route/api/route.proto b/protos/route/api/route.proto new file mode 100644 index 0000000..8749e6b --- /dev/null +++ b/protos/route/api/route.proto @@ -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 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; +} diff --git a/server.js b/server.js index 9abbb86..379eb1d 100644 --- a/server.js +++ b/server.js @@ -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 // ============================================================