1113 lines
45 KiB
TypeScript
1113 lines
45 KiB
TypeScript
import http from "node:http"
|
|
import https from "node:https"
|
|
import { randomBytes } from "node:crypto"
|
|
import { exportRouterConfiguration } from "./router-config-export.js"
|
|
import type { Server } from "../db/schema.js"
|
|
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
|
import { readOspfEffectiveDisabled } from "./ospf-effective-disabled.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 watchRosResponseFailure(res: http.IncomingMessage, reject: (error: Error) => void): void {
|
|
res.once("aborted", () => reject(new Error("RouterOS response interrupted (ECONNRESET)")))
|
|
// IncomingMessage errors are separate from ClientRequest errors after headers arrive.
|
|
res.once("error", reject)
|
|
}
|
|
|
|
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) => {
|
|
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
|
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
|
|
|
|
// An already-aborted signal can settle before the request is assigned.
|
|
// eslint-disable-next-line prefer-const
|
|
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) => {
|
|
watchRosResponseFailure(res, error => settle(() => reject(error)))
|
|
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) => {
|
|
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
|
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) => {
|
|
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
|
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()
|
|
})
|
|
}
|
|
|
|
/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */
|
|
function rosRawRequest(
|
|
params: MikrotikConnectParams,
|
|
opts: { method: "GET" | "POST"; path: string; body?: Record<string, string | string[]>; timeoutMs: number },
|
|
): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const basePath = params.apiPath ?? "/rest"
|
|
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
|
|
const payload = opts.body ? JSON.stringify(opts.body) : undefined
|
|
|
|
const options: https.RequestOptions = {
|
|
hostname: params.host,
|
|
port: params.port,
|
|
path: basePath + opts.path,
|
|
method: opts.method,
|
|
headers: {
|
|
Authorization: authHeader,
|
|
...(payload
|
|
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
|
: {}),
|
|
},
|
|
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
|
|
}
|
|
|
|
const lib = params.useSsl ? https : http
|
|
|
|
const req = lib.request(options, (res) => {
|
|
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
|
const chunks: Buffer[] = []
|
|
res.on("data", (chunk: Buffer) => { chunks.push(chunk) })
|
|
res.on("end", () => {
|
|
const buf = Buffer.concat(chunks)
|
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new MikrotikError(res.statusCode ?? 0, opts.path, buf.toString("utf8").slice(0, 200)))
|
|
return
|
|
}
|
|
resolve(buf)
|
|
})
|
|
})
|
|
|
|
const timer = setTimeout(() => {
|
|
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${opts.timeoutMs / 1000}s`))
|
|
}, opts.timeoutMs)
|
|
req.on("close", () => clearTimeout(timer))
|
|
req.on("error", (err) => {
|
|
clearTimeout(timer)
|
|
reject(err)
|
|
})
|
|
if (payload) req.write(payload)
|
|
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) => {
|
|
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
|
|
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 {
|
|
reject(new Error("Некорректное JSON-подтверждение RouterOS: результат изменения неизвестен"))
|
|
}
|
|
})
|
|
})
|
|
|
|
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}`)
|
|
}
|
|
|
|
/** Лимит команды `/file/get`: выше 61439 байт RouterOS молча отдаёт 0 байт. */
|
|
const MAX_FILE_GET_BYTES = 61439
|
|
|
|
const RET_MARKER = Buffer.from('"ret":', "latin1")
|
|
const CONTENTS_MARKER = Buffer.from('"contents":', "latin1")
|
|
|
|
function assertRosFileReadable(entry: { name: string; size: number }): void {
|
|
if (entry.size >= MAX_FILE_GET_BYTES) {
|
|
throw new Error(
|
|
`Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — `
|
|
+ "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)",
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Превью ответа RouterOS для диагностики: непечатаемые байты → `\uXXXX`. */
|
|
function rosResponsePreview(raw: Buffer, limit = 200): string {
|
|
const slice = raw.subarray(0, limit)
|
|
let out = ""
|
|
for (const byte of slice) {
|
|
if (byte >= 0x20 && byte < 0x7f) out += String.fromCharCode(byte)
|
|
else out += `\\u${byte.toString(16).padStart(4, "0")}`
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte
|
|
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
|
|
* (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486).
|
|
* Значение извлекаем напрямую из сырых байтов, без парсинга всего ответа.
|
|
* Команда `get` кладёт результат в поле `ret` (`!done=ret=...`), команда `print` — в `contents`.
|
|
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API
|
|
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
|
*/
|
|
export function extractRosContentsField(raw: Buffer, field: "contents" | "data" = "contents"): Buffer {
|
|
const start = field === "data"
|
|
? findRosStringField(raw, Buffer.from('"data":', "latin1"))
|
|
: findRosStringField(raw, RET_MARKER) ?? findRosStringField(raw, CONTENTS_MARKER)
|
|
if (start == null) {
|
|
if (field === "data") throw new Error("Ответ RouterOS не содержит строковое поле data")
|
|
throw new Error(`ответ не содержит поле ret/contents (${rosResponsePreview(raw)})`)
|
|
}
|
|
|
|
const out: number[] = []
|
|
let i = start
|
|
while (i < raw.length) {
|
|
const byte = raw[i]
|
|
if (byte === 0x22) return Buffer.from(out) // закрывающая кавычка
|
|
if (byte !== 0x5c) { // обычный байт
|
|
out.push(byte)
|
|
i += 1
|
|
continue
|
|
}
|
|
const esc = raw[i + 1]
|
|
if (esc === undefined) break
|
|
i += 2
|
|
switch (esc) {
|
|
case 0x22: out.push(0x22); break // \"
|
|
case 0x5c: out.push(0x5c); break // \\
|
|
case 0x2f: out.push(0x2f); break // \/
|
|
case 0x62: out.push(0x08); break // \b
|
|
case 0x66: out.push(0x0c); break // \f
|
|
case 0x6e: out.push(0x0a); break // \n
|
|
case 0x72: out.push(0x0d); break // \r
|
|
case 0x74: out.push(0x09); break // \t
|
|
case 0x75: { // \uXXXX
|
|
const hex = raw.subarray(i, i + 4).toString("latin1")
|
|
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("некорректный \\u-escape в содержимом файла")
|
|
i += 4
|
|
for (const b of Buffer.from(String.fromCharCode(parseInt(hex, 16)), "utf8")) out.push(b)
|
|
break
|
|
}
|
|
default: out.push(esc)
|
|
}
|
|
}
|
|
throw new Error("строка содержимого файла не закрыта")
|
|
}
|
|
|
|
/**
|
|
* Ищет ключ JSON и возвращает индекс первого байта строкового значения (после открывающей кавычки).
|
|
* Возвращает null, если ключа нет или за ним не строка.
|
|
*/
|
|
function findRosStringField(raw: Buffer, marker: Buffer): number | null {
|
|
let from = 0
|
|
for (;;) {
|
|
const at = raw.indexOf(marker, from)
|
|
if (at < 0) return null
|
|
let i = at + marker.length
|
|
while (i < raw.length && isRosJsonSpace(raw[i])) i += 1
|
|
if (raw[i] === 0x22) return i + 1
|
|
from = at + marker.length
|
|
}
|
|
}
|
|
|
|
function isRosJsonSpace(byte: number): boolean {
|
|
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d
|
|
}
|
|
|
|
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 ─────────────────────────────────────────────────────────────
|
|
|
|
/** Имя параметра из ошибки RouterOS `unknown parameter <name>` (400) — для деградации запроса. */
|
|
function unknownParameterName(error: MikrotikError): string | undefined {
|
|
if (error.statusCode !== 400) return undefined
|
|
const match = error.body.match(/unknown parameter\s+"?([A-Za-z0-9_-]+)"?/i)
|
|
return match?.[1]
|
|
}
|
|
|
|
/**
|
|
* RouterOS отклоняет `export-passphrase` короче 8 символов:
|
|
* `Failure: If used, passphrase must be at least 8 chars long!`.
|
|
* Та же ошибка приходит, если политика `sensitive` не даёт применить sensitive-параметр.
|
|
*/
|
|
export function isPassphraseTooShortError(error: unknown): boolean {
|
|
return (
|
|
error instanceof MikrotikError &&
|
|
error.statusCode === 400 &&
|
|
/passphrase/i.test(error.body) &&
|
|
/at least\s*8/i.test(error.body)
|
|
)
|
|
}
|
|
|
|
/** Пароль .p12, который RouterOS примет гарантированно (≥8 символов). */
|
|
function generateExportPassphrase(): string {
|
|
return randomBytes(12).toString("base64url")
|
|
}
|
|
|
|
const EXPORT_PASSPHRASE_HINT =
|
|
"RouterOS отклонил пароль .p12. Экспорт приватного ключа (export-passphrase — sensitive-параметр) " +
|
|
"разрешён только пользователю, у которого в политике группы есть «sensitive». " +
|
|
"Проверьте: /user print → группа API-пользователя → /user group print; добавьте sensitive в policy группы " +
|
|
"(в группе full она уже есть)."
|
|
|
|
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 readOspfEffectiveDisabled<RosOspfArea>(await this.get<unknown>("/routing/ospf/area"), id =>
|
|
this.post("/routing/ospf/area/get", { ".id": id, "value-name": "disabled" }))
|
|
}
|
|
|
|
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
|
|
return readOspfEffectiveDisabled<RosOspfInterfaceTemplate>(await this.get<unknown>("/routing/ospf/interface-template"), id =>
|
|
this.post("/routing/ospf/interface-template/get", { ".id": id, "value-name": "disabled" }))
|
|
}
|
|
|
|
async getOspfInstances(): Promise<RosOspfInstance[]> {
|
|
return readOspfEffectiveDisabled<RosOspfInstance>(await this.get<unknown>("/routing/ospf/instance"), id =>
|
|
this.post("/routing/ospf/instance/get", { ".id": id, "value-name": "disabled" }))
|
|
}
|
|
|
|
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 getIpv6Routes(): Promise<RosIpRoute[]> {
|
|
return this.get<RosIpRoute[]>("/ipv6/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[]> {
|
|
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
throw new RangeError("limit должен быть положительным целым числом.")
|
|
}
|
|
// REST query parameters filter properties; limit is not a print row limit.
|
|
// Read only the default buffer and retain its latest entries in native order.
|
|
const entries = await this.get<RosLogEntry[]>("/log?buffer=memory&.proplist=.id,time,topics,message")
|
|
return entries.slice(-Math.min(limit, 200))
|
|
}
|
|
|
|
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
|
|
}, signal?: AbortSignal): 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, signal)
|
|
}
|
|
|
|
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<{ id: string; name: string; size: number }>> {
|
|
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) => ({
|
|
id: String(row[".id"] ?? ""),
|
|
name: String(row.name ?? ""),
|
|
size: Number(row.size ?? 0),
|
|
}))
|
|
.filter((row) => row.name.length > 0 && row.id.length > 0)
|
|
}
|
|
|
|
/** Поиск файла в `/file` по полному имени, базовому имени или `flash/<base>`. */
|
|
private async findFileEntry(name: string): Promise<{ id: string; name: string; size: number } | undefined> {
|
|
const base = routerFileBasename(name)
|
|
const files = await this.listFiles()
|
|
return files.find((file) => file.name === name)
|
|
?? files.find((file) => file.name === base)
|
|
?? files.find((file) => file.name === `flash/${base}`)
|
|
?? files.find((file) => routerFileBasename(file.name) === base)
|
|
}
|
|
|
|
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 (error) {
|
|
if (!(error instanceof MikrotikError) || ![404, 405].includes(error.statusCode)) throw error
|
|
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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Чтение содержимого файла RouterOS. Путь `GET /file/<name>` не существует — RouterOS
|
|
* отвечает `no such command prefix`; используются команды `get`/`print`.
|
|
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
|
*/
|
|
private async readFileContents(
|
|
target: { id?: string; name?: string },
|
|
timeoutMs = 30_000,
|
|
): Promise<Buffer> {
|
|
const readOnce = async (id?: string, name?: string): Promise<Buffer> => {
|
|
const strategies: Array<{ path: string; body: Record<string, string | string[]> }> = []
|
|
if (id) {
|
|
strategies.push({ path: "/file/get", body: { ".id": id, "value-name": "contents" } })
|
|
strategies.push({ path: "/file/get", body: { ".id": id, ".proplist": "contents" } })
|
|
}
|
|
if (name) {
|
|
strategies.push({ path: "/file/print", body: { ".proplist": "contents", ".query": [`name=${name}`] } })
|
|
}
|
|
if (strategies.length === 0) throw new Error("не задан файл для чтения")
|
|
|
|
let lastError: unknown
|
|
for (const strategy of strategies) {
|
|
try {
|
|
const raw = await rosRawRequest(this.params, {
|
|
method: "POST",
|
|
path: strategy.path,
|
|
body: strategy.body,
|
|
timeoutMs,
|
|
})
|
|
const content = extractRosContentsField(raw)
|
|
if (content.length > 0) return content
|
|
lastError = new Error("RouterOS вернул пустое содержимое файла")
|
|
} catch (error) {
|
|
lastError = error
|
|
}
|
|
}
|
|
throw lastError instanceof Error ? lastError : new Error("не удалось прочитать содержимое файла")
|
|
}
|
|
|
|
try {
|
|
return await readOnce(target.id, target.name)
|
|
} catch (error) {
|
|
// `.id` мог устареть (файл пересоздан) — пробуем один раз найти его заново по имени.
|
|
if (!target.name) throw error
|
|
const fresh = await this.findFileEntry(target.name)
|
|
if (!fresh || fresh.id === target.id) throw error
|
|
return readOnce(fresh.id, fresh.name)
|
|
}
|
|
}
|
|
|
|
/** Скачивание содержимого файла RouterOS: `/file/get` + побайтовый разбор (лимит 60 КБ). */
|
|
async downloadFile(
|
|
target: { name: string; id?: string; size?: number },
|
|
timeoutMs = 30_000,
|
|
): Promise<Buffer> {
|
|
const entry = target.id
|
|
? { id: target.id, name: target.name, size: target.size ?? 0 }
|
|
: await this.findFileEntry(target.name)
|
|
if (!entry) throw new Error(`Файл ${routerFileBasename(target.name)} не найден на RouterOS`)
|
|
assertRosFileReadable(entry)
|
|
return this.readFileContents({ id: entry.id, name: entry.name }, timeoutMs)
|
|
}
|
|
|
|
/** Native text exports larger than /file/get's limit use bounded byte offsets. */
|
|
private async downloadConfigurationFile(target: { id: string; name: string; size: number }): Promise<Buffer> {
|
|
if (!Number.isSafeInteger(target.size) || target.size > MAX_ROS_BODY_BYTES) {
|
|
throw new Error("Экспорт конфигурации больше 8 МиБ или размер не подтверждён")
|
|
}
|
|
const deadline = Date.now() + 120_000
|
|
const remaining = () => {
|
|
const ms = deadline - Date.now()
|
|
if (ms <= 0) throw new Error("Истекло время чтения экспорта RouterOS")
|
|
return Math.min(ms, 30_000)
|
|
}
|
|
const metadata = async () => {
|
|
const rows = await this.get<Array<Record<string, unknown>>>("/file", remaining())
|
|
if (!Array.isArray(rows)) throw new Error("Не подтверждён список файлов экспорта")
|
|
const matches = rows.filter(row => row?.[".id"] === target.id || row?.name === target.name)
|
|
const row = matches[0]
|
|
if (matches.length !== 1 || row[".id"] !== target.id || row.name !== target.name || Number(row.size) !== target.size) {
|
|
throw new Error("Файл экспорта изменился во время чтения")
|
|
}
|
|
return JSON.stringify([row["last-modified"] ?? null, row["creation-time"] ?? null])
|
|
}
|
|
const before = await metadata(), chunks: Buffer[] = []
|
|
for (let offset = 0; offset < target.size;) {
|
|
const length = Math.min(32768, target.size - offset)
|
|
const raw = await rosRawRequest(this.params, {
|
|
method: "POST", path: "/file/read",
|
|
body: { file: target.name, offset: String(offset), "chunk-size": String(length) },
|
|
timeoutMs: remaining(),
|
|
})
|
|
const chunk = extractRosContentsField(raw, "data")
|
|
if (chunk.length !== length) throw new Error("RouterOS вернул неполную порцию экспорта")
|
|
chunks.push(chunk); offset += chunk.length
|
|
}
|
|
if (await metadata() !== before) throw new Error("Файл экспорта изменился во время чтения")
|
|
return Buffer.concat(chunks, target.size)
|
|
}
|
|
|
|
/** Удаление файла RouterOS по `.id` или имени (для временных артефактов экспорта). */
|
|
async removeFile(idOrName: string, timeoutMs = 10_000): Promise<void> {
|
|
const id = idOrName.startsWith("*")
|
|
? idOrName
|
|
: (await this.findFileEntry(idOrName))?.id
|
|
if (!id) return
|
|
await this.delete(`/file/${encodeURIComponent(id)}`, timeoutMs)
|
|
}
|
|
|
|
/**
|
|
* POST с деградацией по несовместимым параметрам: набор полей `/certificate/*` зависит от версии
|
|
* RouterOS (напр. `comment`, `days-valid`). При 400 «unknown parameter X» убираем X и повторяем.
|
|
*/
|
|
private async postTolerant(
|
|
path: string,
|
|
body: Record<string, string>,
|
|
timeoutMs: number,
|
|
maxAttempts = 4,
|
|
): Promise<unknown> {
|
|
const payload: Record<string, string> = { ...body }
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
try {
|
|
return await this.post(path, payload, timeoutMs)
|
|
} catch (error) {
|
|
const param = error instanceof MikrotikError ? unknownParameterName(error) : undefined
|
|
if (!param || !(param in payload)) throw error
|
|
delete payload[param]
|
|
}
|
|
}
|
|
throw new Error(`RouterOS: не удалось выполнить ${path} (несовместимые параметры): ${Object.keys(body).join(", ")}`)
|
|
}
|
|
|
|
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
|
|
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
|
|
return this.postTolerant("/certificate/add", body, timeoutMs)
|
|
}
|
|
|
|
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
|
|
async signCertificate(params: {
|
|
name: string
|
|
ca?: string
|
|
daysValid?: number
|
|
}, timeoutMs = 120_000): Promise<unknown> {
|
|
const body: Record<string, string> = { name: params.name }
|
|
if (params.ca) body.ca = params.ca
|
|
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
|
|
try {
|
|
return await this.postTolerant("/certificate/sign", body, timeoutMs)
|
|
} catch (e) {
|
|
// Некоторые версии REST принимают цель подписи только как .id.
|
|
const certs = await this.getCertificates()
|
|
const row = certs.find((c) => String(c.name ?? "") === params.name)
|
|
const id = row?.[".id"]
|
|
if (!id) throw e
|
|
return await this.postTolerant("/certificate/sign", { ".id": id, ...body }, timeoutMs)
|
|
}
|
|
}
|
|
|
|
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает созданный файл. */
|
|
async exportCertificate(params: {
|
|
name: string
|
|
type: "pkcs12" | "pem"
|
|
passphrase?: string
|
|
}, timeoutMs = 60_000): Promise<{ fileName: string; fileId: string; size: number }> {
|
|
const body: Record<string, string> = { name: params.name, type: params.type }
|
|
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
|
|
let raw: unknown
|
|
try {
|
|
raw = await this.postTolerant("/certificate/export-certificate", body, timeoutMs)
|
|
} catch (e) {
|
|
const certs = await this.getCertificates()
|
|
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
|
|
if (!id) throw e
|
|
raw = await this.postTolerant("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
|
|
}
|
|
void raw
|
|
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
|
|
const ext = params.type === "pkcs12" ? "p12" : "crt"
|
|
const wanted = [`${params.name}.${ext}`, `cert_export_${params.name}.${ext}`]
|
|
const files = await this.listFiles()
|
|
const hit = files.find((f) => wanted.includes(f.name))
|
|
?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name))
|
|
if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`)
|
|
return { fileName: hit.name, fileId: hit.id, size: hit.size }
|
|
}
|
|
|
|
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
|
|
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> {
|
|
let passphrase = params.passphrase?.trim() || generateExportPassphrase()
|
|
let exported: { fileName: string; fileId: string; size: number }
|
|
try {
|
|
exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase })
|
|
} catch (error) {
|
|
if (!isPassphraseTooShortError(error)) throw error
|
|
// RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл.
|
|
const forced = generateExportPassphrase()
|
|
try {
|
|
exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: forced })
|
|
passphrase = forced
|
|
} catch (retryError) {
|
|
if (isPassphraseTooShortError(retryError)) throw new Error(EXPORT_PASSPHRASE_HINT)
|
|
throw retryError
|
|
}
|
|
}
|
|
const content = await this.downloadFile({
|
|
name: exported.fileName,
|
|
id: exported.fileId,
|
|
size: exported.size,
|
|
})
|
|
// Временный файл экспорта на роутере больше не нужен (best-effort, не влияет на результат).
|
|
await this.removeFile(exported.fileId).catch(() => undefined)
|
|
return { fileName: exported.fileName, content, passphrase }
|
|
}
|
|
|
|
async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
|
|
const certs = await this.getCertificates()
|
|
const row = certs.find((c) => String(c.name ?? "") === nameOrId || c[".id"] === nameOrId)
|
|
const id = row?.[".id"]
|
|
if (!id) return
|
|
await this.delete(`/certificate/${encodeURIComponent(id)}`, timeoutMs)
|
|
}
|
|
|
|
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> {
|
|
return exportRouterConfiguration({
|
|
list: () => this.get<unknown>("/file"),
|
|
export: name => this.post("/export", { file: name }, 120_000),
|
|
download: file => this.downloadConfigurationFile(file),
|
|
remove: id => this.removeFile(id),
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
// ── 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"
|
|
}
|
|
}
|