import http from "node:http" import https from "node:https" import type { Server } from "../db/schema.js" import type { RosIdentity, RosInterface, RosIpAddress, RosResource, RosBgpSession, RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance, RosBfdSession, RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult, } from "../types/server.js" // ── connection params ───────────────────────────────────────────────────────── export interface MikrotikConnectParams { host: string port: number useSsl: boolean verifySsl: boolean username: string password: string apiPath?: string // defaults to "/rest" } // ── low-level HTTP helpers ──────────────────────────────────────────────────── function rosRequest( params: MikrotikConnectParams, path: string, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const basePath = params.apiPath ?? "/rest" const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") const options: https.RequestOptions = { hostname: params.host, port: params.port, path: basePath + path, method: "GET", headers: { Authorization: authHeader, "Content-Type": "application/json" }, rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, } const lib = params.useSsl ? https : http const timer = setTimeout(() => { req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) }, timeoutMs) const req = lib.request(options, (res) => { let body = "" res.setEncoding("utf8") res.on("data", (chunk: string) => { body += chunk }) res.on("end", () => { clearTimeout(timer) if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { reject(new MikrotikError(res.statusCode ?? 0, path, body)) return } try { resolve(JSON.parse(body)) } catch { reject(new Error(`Invalid JSON from RouterOS: ${body.slice(0, 200)}`)) } }) }) req.on("error", (err) => { clearTimeout(timer) reject(err) }) req.end() }) } function rosPost( params: MikrotikConnectParams, path: string, body: Record, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const basePath = params.apiPath ?? "/rest" const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") const payload = JSON.stringify(body) const options: https.RequestOptions = { hostname: params.host, port: params.port, path: basePath + path, method: "POST", headers: { Authorization: authHeader, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload), }, rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, } const lib = params.useSsl ? https : http const timer = setTimeout(() => { req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) }, timeoutMs) const req = lib.request(options, (res) => { let buf = "" res.setEncoding("utf8") res.on("data", (chunk: string) => { buf += chunk }) res.on("end", () => { clearTimeout(timer) if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return } try { resolve(JSON.parse(buf)) } catch { reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`)) } }) }) req.on("error", (err) => { clearTimeout(timer); reject(err) }) req.write(payload) req.end() }) } function rosDelete( params: MikrotikConnectParams, path: string, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { const basePath = params.apiPath ?? "/rest" const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") const options: https.RequestOptions = { hostname: params.host, port: params.port, path: basePath + path, method: "DELETE", headers: { Authorization: authHeader, "Content-Type": "application/json" }, rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, } const lib = params.useSsl ? https : http const timer = setTimeout(() => { req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) }, timeoutMs) const req = lib.request(options, (res) => { let body = "" res.setEncoding("utf8") res.on("data", (chunk: string) => { body += chunk }) res.on("end", () => { clearTimeout(timer) if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { reject(new MikrotikError(res.statusCode ?? 0, path, body)); return } resolve() }) }) req.on("error", (err) => { clearTimeout(timer) reject(err) }) req.end() }) } // ── MikrotikClient ───────────────────────────────────────────────────────────── export class MikrotikClient { constructor(private readonly params: MikrotikConnectParams) {} /** Convenience factory from a DB Server row */ static fromServer(server: Server): MikrotikClient { return new MikrotikClient({ host: server.host, port: server.port, useSsl: server.useSsl, verifySsl: server.verifySsl, username: server.username, password: server.password, }) } async get(path: string, timeoutMs = 10_000): Promise { return rosRequest(this.params, path, timeoutMs) as Promise } async post(path: string, body: Record, timeoutMs = 15_000): Promise { return rosPost(this.params, path, body, timeoutMs) as Promise } async delete(path: string, timeoutMs = 10_000): Promise { return rosDelete(this.params, path, timeoutMs) } // ── typed helpers ────────────────────────────────────────────────────────── async getIdentity(): Promise { return this.get("/system/identity") } async getResource(): Promise { return this.get("/system/resource") } async getInterfaces(): Promise { return this.get("/interface") } async getIpAddresses(): Promise { return this.get("/ip/address") } async getBgpSessions(): Promise { return this.get("/routing/bgp/session") } /** Returns the raw (un-typed) BGP session objects — used for debugging */ async getBgpSessionsRaw(): Promise { return this.get("/routing/bgp/session") } // ── OSPF ────────────────────────────────────────────────────────────────── async getOspfNeighbors(): Promise { return this.get("/routing/ospf/neighbor") } async getOspfAreas(): Promise { return this.get("/routing/ospf/area") } async getOspfInterfaceTemplates(): Promise { return this.get("/routing/ospf/interface-template") } async getOspfInstances(): Promise { return this.get("/routing/ospf/instance") } async getBfdSessions(): Promise { return this.get("/routing/bfd/session") } // ── extra endpoints for exec route ──────────────────────────────────────── async getIpRoutes(): Promise { return this.get("/ip/route") } async getFirewallFilters(): Promise { return this.get("/ip/firewall/filter") } async getLogs(limit = 50): Promise { return this.get(`/log?limit=${limit}`) } async ping(address: string, count = 4, interfaceName?: string): Promise { const body: Record = { address, count: String(count), interval: "0.2s", } if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim() return this.post("/tool/ping", body, 20_000) } async bandwidthTest(params: { address: string user: string password: string protocol?: "tcp" | "udp" direction?: "transmit" | "receive" | "both" durationSec?: number }): Promise>> { const body: Record = { address: params.address, user: params.user, password: params.password, protocol: params.protocol ?? "tcp", direction: params.direction ?? "both", duration: `${Math.max(3, params.durationSec ?? 10)}s`, } return this.post>>("/tool/bandwidth-test", body, 30_000) } } // ── Error type ───────────────────────────────────────────────────────────────── export class MikrotikError extends Error { constructor( public readonly statusCode: number, public readonly path: string, public readonly body: string, ) { super(`RouterOS API error ${statusCode} on ${path}: ${body}`) this.name = "MikrotikError" } }