fix(backups): получать нативный экспорт и проверить восстановление из S3
This commit is contained in:
1 parent
03b0896369
commit
c3fb66dcbf
18 files changed
+1405
-120
No files matched your search
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -122,6 +123,8 @@ function rosPost(
|
||||
|
||||
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
|
||||
@@ -900,7 +903,7 @@ export class MikrotikClient {
|
||||
target: { name: string; id?: string; size?: number },
|
||||
timeoutMs = 30_000,
|
||||
): Promise<Buffer> {
|
||||
let entry = target.id
|
||||
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`)
|
||||
@@ -1045,116 +1048,14 @@ export class MikrotikClient {
|
||||
}
|
||||
|
||||
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()
|
||||
return exportRouterConfiguration({
|
||||
list: () => this.get<unknown>("/file"),
|
||||
export: name => this.post("/export", { file: name }, 120_000),
|
||||
download: file => this.downloadFile(file, 30_000),
|
||||
remove: id => this.removeFile(id),
|
||||
})
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user