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
Сохранять снимки в S3-compatible бакет, скачивать и удалять их вместе с локальными файлами. Привести /backups к DNA /servers: Frame, KPI, фильтры и реальное восстановление. Co-authored-by: Cursor <[email protected]>
763 lines
27 KiB
TypeScript
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"
|
|
}
|
|
}
|