Files
MikrotikManager/backend/src/services/mikrotik.ts
T
DenozordecandCursor cff26813b9
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 4m37s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m6s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 16s
feat(backups): добавить S3-хранилище и обновить экран бэкапов
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами.
Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление.

Co-authored-by: Cursor <[email protected]>
2026-09-08 14:06:27 +07:00

763 lines
27 KiB
TypeScript

import http from "node:http"
import https from "node:https"
import type { Server } from "../db/schema.js"
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
import type {
RosIdentity, RosInterface, RosIpAddress, RosResource,
RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
FirewallFamily, FirewallTable,
} from "../types/server.js"
const MAX_ROS_BODY_BYTES = 8 * 1024 * 1024
function appendRosBody(body: string, chunk: string, req?: http.ClientRequest): string {
if (body.length + chunk.length > MAX_ROS_BODY_BYTES) {
req?.destroy(new Error("RouterOS: ответ больше 8 МиБ"))
return body
}
return body + chunk
}
// ── 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 = appendRosBody(body, chunk, req) })
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,
externalSignal?: AbortSignal,
): 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
let req: ReturnType<typeof lib.request> | undefined
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
if (req) {
req.setTimeout(0)
req.removeListener("timeout", onSocketTimeout)
}
externalSignal?.removeEventListener("abort", onExternalAbort)
fn()
}
const onExternalAbort = () => {
req?.destroy(new DOMException("Aborted", "AbortError"))
}
const onSocketTimeout = () => {
req?.destroy()
settle(() => reject(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)))
}
if (externalSignal?.aborted) {
settle(() => reject(new DOMException("Aborted", "AbortError")))
return
}
externalSignal?.addEventListener("abort", onExternalAbort)
req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
res.on("end", () => {
settle(() => {
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.setTimeout(timeoutMs)
req.on("timeout", onSocketTimeout)
req.on("error", (err) => {
settle(() => reject(err))
})
req.write(payload)
req.end()
})
}
function rosPut(
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: "PUT",
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 = appendRosBody(buf, chunk, req) })
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(buf.trim() ? 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 = appendRosBody(body, chunk, req) })
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()
})
}
function rosPatch(
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: "PATCH",
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 = appendRosBody(buf, chunk, req) })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
if (buf.trim() === "") {
resolve({})
return
}
try { resolve(JSON.parse(buf)) } catch { resolve({}) }
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.write(payload)
req.end()
})
}
/**
* RouterOS REST для одиночных меню (`/system/resource`, `/system/identity`) отдаёт JSON-массив из одного объекта `[{...}]`,
* а не сам объект. Без распаковки поля читаются с массива → всегда undefined и в БД уходят нули при «успехе».
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
*/
function unwrapRestSingle<T extends object>(raw: unknown): T {
if (Array.isArray(raw)) {
const first = raw[0]
if (first && typeof first === "object") return first as T
throw new Error("RouterOS REST: пустой массив вместо записи")
}
if (raw && typeof raw === "object") return raw as T
throw new Error("RouterOS REST: неожиданное тело ответа")
}
function routerFileBasename(fileName: string): string {
return fileName.replace(/^\/+/, "").split("/").pop() ?? fileName
}
function matchesUploadedFile(entryName: string, requested: string): boolean {
const base = routerFileBasename(requested)
return entryName === requested
|| entryName === base
|| entryName === `flash/${base}`
|| entryName.endsWith(`/${base}`)
}
export function firewallRestPath(
family: FirewallFamily,
table: FirewallTable | "address-list",
): string {
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
return `${root}/${table}`
}
export function encodeRosId(rosId: string): string {
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
return encodeURIComponent(id)
}
// ── 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, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
async put<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPut(this.params, path, body, timeoutMs) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
return rosDelete(this.params, path, timeoutMs)
}
async patch<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPatch(this.params, path, body, timeoutMs) as Promise<T>
}
// ── typed helpers ──────────────────────────────────────────────────────────
async getIdentity(): Promise<RosIdentity> {
const raw = await this.get<unknown>("/system/identity")
return unwrapRestSingle<RosIdentity>(raw)
}
async getResource(): Promise<RosResource> {
const raw = await this.get<unknown>("/system/resource")
if (Array.isArray(raw)) {
if (raw.length === 0) throw new Error("RouterOS REST: пустой массив вместо записи /system/resource")
if (raw.length === 1) return unwrapRestSingle<RosResource>(raw[0])
/** Несколько объектов: взять строку с total-memory (агрегат), а не дочернюю запись с «чужим» uptime. */
const objs = raw.filter((x): x is Record<string, unknown> => x != null && typeof x === "object" && !Array.isArray(x))
/** На части железа несколько строк; «первая с непустым total-memory» может парситься в 0 — берём запись с максимальным RAM. */
let best: Record<string, unknown> | undefined
let bestBytes = -1
for (const o of objs) {
const tm = parseRosDataSizeBytes(o["total-memory"])
const fm = parseRosDataSizeBytes(o["free-memory"])
const score = tm > 0 ? tm : fm
if (score > bestBytes) {
bestBytes = score
best = o
}
}
const main = objs.find((o) => {
const tm = o["total-memory"]
return tm != null && String(tm).trim() !== ""
})
return ((bestBytes > 0 ? best : undefined) ?? main ?? objs[0]) as unknown as RosResource
}
return unwrapRestSingle<RosResource>(raw)
}
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")
}
async setOspfInterfaceTemplateCost(templateId: string, cost: number): Promise<void> {
const safeId = templateId.startsWith("*") ? templateId : `*${templateId.replace(/^\*/, "")}`
await this.patch(`/routing/ospf/interface-template/${encodeURIComponent(safeId)}`, { cost: String(cost) })
}
// ── extra endpoints for exec route ────────────────────────────────────────
async getIpRoutes(): Promise<RosIpRoute[]> {
return this.get<RosIpRoute[]>("/ip/route")
}
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
return this.getFirewallRules("ip", "filter")
}
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
}
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
}
async ping(
address: string,
count = 4,
interfaceName?: string,
options?: { interval?: string },
): Promise<RosPingResult[]> {
const body: Record<string, string> = {
address,
count: String(count),
}
if (options?.interval && options.interval.trim()) body.interval = options.interval.trim()
else body.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)
}
async getCertificates(): Promise<Array<Record<string, string | undefined>>> {
const raw = await this.get<unknown>("/certificate")
if (!Array.isArray(raw)) return []
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
}
async listFiles(): Promise<Array<{ name: string }>> {
const raw = await this.get<unknown>("/file")
if (!Array.isArray(raw)) return []
return raw
.filter((row): row is Record<string, unknown> => row != null && typeof row === "object")
.map((row) => ({ name: String(row.name ?? "") }))
.filter((row) => row.name.length > 0)
}
private async resolveUploadedFileName(requested: string): Promise<string> {
const files = await this.listFiles()
const match = files.find((file) => matchesUploadedFile(file.name, requested))
if (!match) {
throw new Error(`Файл ${routerFileBasename(requested)} не найден на RouterOS после загрузки`)
}
return match.name
}
async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise<string> {
const normalized = routerFileBasename(fileName)
const payload = contents.endsWith("\n") ? contents : `${contents}\n`
const candidates = [`flash/${normalized}`, normalized]
let lastError: unknown
for (const name of candidates) {
try {
await this.put("/file", { name, contents: payload }, timeoutMs)
return await this.resolveUploadedFileName(name)
} catch (error) {
lastError = error
}
try {
await this.post("/file/add", { name, contents: payload, type: "file" }, timeoutMs)
return await this.resolveUploadedFileName(name)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
}
async importUploadedFile(fileName: string): Promise<unknown> {
try {
return await this.post("/import", { "file-name": fileName }, 120_000)
} catch {
return await this.post("/execute", { script: `/import file-name="${fileName}"` }, 120_000)
}
}
async importCertificate(params: {
fileName: string
name: string
trusted?: boolean
trustStore?: string
passphrase?: string
}): Promise<unknown> {
const body: Record<string, string> = {
"file-name": params.fileName,
name: params.name,
trusted: params.trusted === false ? "no" : "yes",
"trust-store": params.trustStore?.trim() || "www,api",
}
if (params.passphrase?.trim()) body.passphrase = params.passphrase.trim()
return this.post("/certificate/import", body, 60_000)
}
async applyCertificateToServices(certName: string): Promise<void> {
const body = {
disabled: "no",
certificate: certName,
}
for (const serviceName of ["www-ssl", "api-ssl"]) {
await this.patchIpService(serviceName, body)
}
}
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
try {
await this.patch(pathByName, body, 30_000)
return
} catch {
const services = await this.get<Array<Record<string, string | undefined>>>("/ip/service")
const row = services.find((service) => String(service.name ?? "") === serviceName)
const id = row?.[".id"]
if (!id) throw new Error(`Сервис RouterOS ${serviceName} не найден`)
await this.patch(`/ip/service/${encodeURIComponent(id)}`, body, 30_000)
}
}
async exportConfigScript(): Promise<string> {
const raw = await this.post<unknown>("/console/export", {}, 30_000)
const asText = (v: unknown): string | null => {
if (typeof v === "string") return v.trim().length > 0 ? v : null
if (Array.isArray(v)) {
const parts = v
.map((item) => asText(item))
.filter((s): s is string => typeof s === "string" && s.length > 0)
return parts.length > 0 ? parts.join("\n") : null
}
if (v && typeof v === "object") {
const rec = v as Record<string, unknown>
const direct =
asText(rec.output) ??
asText(rec.stdout) ??
asText(rec.data) ??
asText(rec.ret) ??
asText(rec["!re"])
if (direct) return direct
const serialized = JSON.stringify(rec, null, 2)
return serialized.length > 2 ? serialized : null
}
return null
}
const txt = asText(raw)
if (txt && txt.trim().length > 0) return txt
// Fallback: на части RouterOS /console/export возвращает пустое тело.
// Тогда строим .rsc-скрипт из основных read-only разделов REST.
return this.buildSyntheticExportScript()
}
private async buildSyntheticExportScript(): Promise<string> {
const now = new Date().toISOString()
const lines: string[] = [
"# synthetic export generated by MikrotikManager",
`# generated-at: ${now}`,
"",
]
const identity = await this.getIdentity().catch(() => null)
if (identity?.name) {
lines.push("/system identity")
lines.push(`set name="${identity.name.replace(/"/g, "\\\"")}"`)
lines.push("")
}
const interfaces = await this.getInterfaces().catch(() => [])
if (interfaces.length > 0) {
lines.push("/interface")
for (const i of interfaces) {
if (!i.name) continue
const mtu = i["actual-mtu"] ?? i.mtu
const parts = [
`name="${String(i.name).replace(/"/g, "\\\"")}"`,
mtu ? `mtu=${mtu}` : null,
i.disabled === "true" ? "disabled=yes" : "disabled=no",
].filter((v): v is string => typeof v === "string")
lines.push(`:put "interface ${parts.join(" ")}"`)
}
lines.push("")
}
const addrs = await this.getIpAddresses().catch(() => [])
if (addrs.length > 0) {
lines.push("/ip address")
for (const a of addrs) {
if (!a.address || !a.interface) continue
const comment = a.comment ? ` comment="${String(a.comment).replace(/"/g, "\\\"")}"` : ""
lines.push(`add address=${a.address} interface="${String(a.interface).replace(/"/g, "\\\"")}"${comment}`)
}
lines.push("")
}
const routes = await this.getIpRoutes().catch(() => [])
if (routes.length > 0) {
lines.push("/ip route")
for (const r of routes) {
const dst = r["dst-address"]
const gw = r["gateway"]
if (!dst || !gw) continue
const distance = r.distance ? ` distance=${r.distance}` : ""
lines.push(`add dst-address=${dst} gateway=${gw}${distance}`)
}
lines.push("")
}
const firewall = await this.getFirewallFilters().catch(() => [])
if (firewall.length > 0) {
lines.push("/ip firewall filter")
for (const f of firewall) {
if (!f.chain || !f.action) continue
const parts = [`chain=${f.chain}`, `action=${f.action}`]
if (f.protocol) parts.push(`protocol=${f.protocol}`)
if (f["src-address"]) parts.push(`src-address=${f["src-address"]}`)
if (f["dst-address"]) parts.push(`dst-address=${f["dst-address"]}`)
if (f["dst-port"]) parts.push(`dst-port=${f["dst-port"]}`)
if (f["src-port"]) parts.push(`src-port=${f["src-port"]}`)
if (f.disabled === "true") parts.push("disabled=yes")
lines.push(`add ${parts.join(" ")}`)
}
lines.push("")
}
if (lines.length <= 3) {
throw new Error("RouterOS вернул пустой export и fallback-данные недоступны")
}
return lines.join("\n")
}
}
// ── 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"
}
}