fix(backups): читать экспорт RouterOS порциями без потери данных

This commit is contained in:
Denozordec committed 2026-10-14 10:38:49 +07:00
1 parent c3fb66dcbf
commit 41a76f1163
12 files changed
+1205 -18

No files matched your search

+43 -3
View File
@@ -455,9 +455,12 @@ function rosResponsePreview(raw: Buffer, limit = 200): string {
* @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): Buffer {
const start = findRosStringField(raw, RET_MARKER) ?? findRosStringField(raw, CONTENTS_MARKER)
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)})`)
}
@@ -911,6 +914,43 @@ export class MikrotikClient {
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("*")
@@ -1051,7 +1091,7 @@ export class MikrotikClient {
return exportRouterConfiguration({
list: () => this.get<unknown>("/file"),
export: name => this.post("/export", { file: name }, 120_000),
download: file => this.downloadFile(file, 30_000),
download: file => this.downloadConfigurationFile(file),
remove: id => this.removeFile(id),
})
}