fix(backups): получать нативный экспорт и проверить восстановление из S3

This commit is contained in:
Denozordec committed 2026-10-14 10:31:14 +07:00
1 parent 03b0896369
commit c3fb66dcbf
18 files changed
+1405 -120

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:backups": "tsx src/services/router-config-export.test.ts && tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.test.ts",
"test:live-maps": "tsx src/services/gre-keepalive.test.ts && tsx src/services/gre-read.test.ts && tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts && tsx src/routes/ospf-legacy.test.ts",
"test": "node scripts/test.mjs",
+10 -109
View File
@@ -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 ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import {createServer} from 'node:http'
import {MikrotikClient} from './mikrotik.js'
const good='# oct/14/2026 by RouterOS 7.24.5\n/system identity\nset name=export-test\n'
for(const mode of ['success','malformed-list','export-error','truncated','section-error','oversize','cleanup-error','foreign-id']){
let ownName='',exportCalls=0,readCalls=0,deletes=0
const original={'.id':'*ORIG',name:'important.rsc',size:'2'}
let own:Record<string,string>|null=null
const content=mode==='section-error'?good+'#error exporting /routing (timeout)\n':mode==='oversize'?good+'#'.repeat(70000):good
const fixture=createServer(async(req,res)=>{let input='';for await(const b of req)input+=b;const body=input?JSON.parse(input):{};const reply=(status:number,value:unknown)=>{res.statusCode=status;res.setHeader('content-type','application/json');res.end(JSON.stringify(value))}
if(req.method==='GET'&&req.url==='/rest/file')return reply(200,mode==='malformed-list'?{}:[original,...(own?[own]:[])])
if(req.method==='POST'&&req.url==='/rest/export'){exportCalls++;ownName=body.file;assert(/^mmapp-export-[a-f0-9-]{36}\.rsc$/.test(ownName));assert.deepEqual(Object.keys(body),['file']);own={'.id':mode==='foreign-id'?'*ORIG':'*TEMP',name:ownName,size:String(Buffer.byteLength(content))};return reply(mode==='export-error'?500:200,mode==='export-error'?{error:'export failed'}:[])}
if(req.method==='POST'&&req.url==='/rest/file/get'){readCalls++;return reply(200,[{ret:mode==='truncated'?content.slice(0,20):content}])}
if(req.method==='DELETE'&&req.url==='/rest/file/*TEMP'){deletes++;if(mode==='cleanup-error')return reply(500,{error:'cleanup denied'});own=null;return reply(200,{})}
assert.fail('Unexpected request '+req.method+' '+req.url)
});await new Promise<void>(r=>fixture.listen(0,'127.0.0.1',r));const address=fixture.address();assert(address&&typeof address!=='string');const c=new MikrotikClient({host:'127.0.0.1',port:address.port,useSsl:false,verifySsl:false,username:'fixture',password:'fixture'});
try{if(mode==='success'){assert.equal(await c.exportConfigScript(),good);assert.equal(readCalls,1)}else await assert.rejects(c.exportConfigScript(), mode);assert.equal(original.name,'important.rsc');if(mode==='malformed-list')assert.equal(exportCalls,0);else assert.equal(exportCalls,1);if(!['malformed-list','foreign-id'].includes(mode))assert.equal(deletes,1);if(!['cleanup-error','foreign-id'].includes(mode))assert.equal(own,null);if(mode==='oversize')assert.equal(readCalls,0)}finally{await new Promise<void>(r=>fixture.close(()=>r()))}
}
console.log('native configuration export: 8 HTTP scenarios passed; original file never deleted')
@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto"
type ExportFile = { id: string; name: string; size: number }
type ExportDeps = {
list: () => Promise<unknown>
export: (name: string) => Promise<unknown>
download: (file: ExportFile) => Promise<Buffer>
remove: (id: string) => Promise<void>
}
function files(raw: unknown): ExportFile[] {
if (!Array.isArray(raw)) throw new Error("Не подтверждён список файлов RouterOS")
const ids = new Set<string>(), names = new Set<string>()
return raw.map(row => {
if (!row || typeof row !== "object" || typeof row[".id"] !== "string" || !row[".id"] || typeof row.name !== "string" || !row.name || ids.has(row[".id"]) || names.has(row.name)) throw new Error("Некорректный список файлов RouterOS")
ids.add(row[".id"]); names.add(row.name)
const size = Number(row.size ?? 0)
if (!Number.isSafeInteger(size) || size < 0) throw new Error("Не подтверждён размер файла RouterOS")
return { id: row[".id"], name: row.name, size }
})
}
/** Native export is file based. Never replace a failed backup with a partial synthetic script. */
export async function exportRouterConfiguration(deps: ExportDeps): Promise<string> {
const name = `mmapp-export-${randomUUID()}.rsc`
const ownName = (value: string) => value === name || value === `flash/${name}`
const before = files(await deps.list())
if (before.some(row => ownName(row.name))) throw new Error("Временный файл экспорта уже существует")
const originalIds = new Set(before.map(row => row.id))
try {
await deps.export(name)
let target: ExportFile | undefined
for (let attempt = 0; attempt < 8; attempt++) {
const candidates = files(await deps.list()).filter(row => ownName(row.name))
if (candidates.length > 1) throw new Error("Неоднозначный временный файл экспорта")
target = candidates[0]
if (target?.size) break
if (attempt < 7) await new Promise(resolve => setTimeout(resolve, 250))
}
if (!target?.size || originalIds.has(target.id)) throw new Error("RouterOS не создал полный файл экспорта")
const content = await deps.download(target)
const script = content.toString("utf8")
if (content.length !== target.size || !/^#.*by RouterOS/m.test(script) || /^\s*#\s*error\b/im.test(script) || script.includes("\uFFFD")) throw new Error("Не подтверждён полный экспорт конфигурации RouterOS")
return script
} finally {
// A failed/unknown command can still have created the file. Delete only this run's name and ID.
const current = files(await deps.list()).filter(row => ownName(row.name))
for (const row of current) {
if (originalIds.has(row.id)) throw new Error("ID временного экспорта принадлежит исходному файлу")
await deps.remove(row.id)
}
if (files(await deps.list()).some(row => ownName(row.name))) throw new Error(`Не подтверждена очистка временного экспорта ${name}`)
}
}