305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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<unknown> {
|
|
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<string, string>,
|
|
timeoutMs: number,
|
|
): Promise<unknown> {
|
|
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<void> {
|
|
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<T>(path: string, timeoutMs = 10_000): Promise<T> {
|
|
return rosRequest(this.params, path, timeoutMs) as Promise<T>
|
|
}
|
|
|
|
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
|
|
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
|
|
}
|
|
|
|
async delete(path: string, timeoutMs = 10_000): Promise<void> {
|
|
return rosDelete(this.params, path, timeoutMs)
|
|
}
|
|
|
|
// ── typed helpers ──────────────────────────────────────────────────────────
|
|
|
|
async getIdentity(): Promise<RosIdentity> {
|
|
return this.get<RosIdentity>("/system/identity")
|
|
}
|
|
|
|
async getResource(): Promise<RosResource> {
|
|
return this.get<RosResource>("/system/resource")
|
|
}
|
|
|
|
async getInterfaces(): Promise<RosInterface[]> {
|
|
return this.get<RosInterface[]>("/interface")
|
|
}
|
|
|
|
async getIpAddresses(): Promise<RosIpAddress[]> {
|
|
return this.get<RosIpAddress[]>("/ip/address")
|
|
}
|
|
|
|
async getBgpSessions(): Promise<RosBgpSession[]> {
|
|
return this.get<RosBgpSession[]>("/routing/bgp/session")
|
|
}
|
|
|
|
/** Returns the raw (un-typed) BGP session objects — used for debugging */
|
|
async getBgpSessionsRaw(): Promise<unknown[]> {
|
|
return this.get<unknown[]>("/routing/bgp/session")
|
|
}
|
|
|
|
// ── OSPF ──────────────────────────────────────────────────────────────────
|
|
|
|
async getOspfNeighbors(): Promise<RosOspfNeighbor[]> {
|
|
return this.get<RosOspfNeighbor[]>("/routing/ospf/neighbor")
|
|
}
|
|
|
|
async getOspfAreas(): Promise<RosOspfArea[]> {
|
|
return this.get<RosOspfArea[]>("/routing/ospf/area")
|
|
}
|
|
|
|
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
|
|
return this.get<RosOspfInterfaceTemplate[]>("/routing/ospf/interface-template")
|
|
}
|
|
|
|
async getOspfInstances(): Promise<RosOspfInstance[]> {
|
|
return this.get<RosOspfInstance[]>("/routing/ospf/instance")
|
|
}
|
|
|
|
async getBfdSessions(): Promise<RosBfdSession[]> {
|
|
return this.get<RosBfdSession[]>("/routing/bfd/session")
|
|
}
|
|
|
|
// ── extra endpoints for exec route ────────────────────────────────────────
|
|
|
|
async getIpRoutes(): Promise<RosIpRoute[]> {
|
|
return this.get<RosIpRoute[]>("/ip/route")
|
|
}
|
|
|
|
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
|
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
|
|
}
|
|
|
|
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
|
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
|
|
}
|
|
|
|
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
|
|
const body: Record<string, string> = {
|
|
address,
|
|
count: String(count),
|
|
interval: "0.2s",
|
|
}
|
|
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
|
|
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
|
|
}
|
|
|
|
async bandwidthTest(params: {
|
|
address: string
|
|
user: string
|
|
password: string
|
|
protocol?: "tcp" | "udp"
|
|
direction?: "transmit" | "receive" | "both"
|
|
durationSec?: number
|
|
}): Promise<Array<Record<string, string>>> {
|
|
const body: Record<string, string> = {
|
|
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<Array<Record<string, string>>>("/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"
|
|
}
|
|
}
|