fix(backups): читать экспорт RouterOS порциями без потери данных
This commit is contained in:
1 parent
c3fb66dcbf
commit
41a76f1163
12 files changed
+1205
-18
No files matched your search
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user