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),
})
}
@@ -2,19 +2,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']){
for(const mode of ['success','malformed-list','export-error','truncated','section-error','oversize','cleanup-error','foreign-id','short-chunk','missing-data','changed-file','utf8-boundary','max-size']){
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 content=mode==='utf8-boundary'?good+'#'.repeat(32767-Buffer.byteLength(good))+'я'.repeat(20000):mode==='section-error'?good+'#error exporting /routing (timeout)\n':['oversize','short-chunk','missing-data','changed-file'].includes(mode)?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/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(mode==='max-size'?8*1024*1024+1:Buffer.byteLength(content))};return reply(mode==='export-error'?500:200,mode==='export-error'?{error:'export failed'}:[])}
if(req.method==='POST'&&req.url==='/rest/file/read'){readCalls++;const offset=Number(body.offset),length=Number(body['chunk-size']);assert.equal(body.file,ownName);assert(length>0&&length<=32768);const bytes=Buffer.from(content).subarray(offset,offset+length);const data=bytes.toString('utf8');if(mode==='utf8-boundary'){const encoded=[];for(const byte of bytes){if(byte===34||byte===92)encoded.push(92,byte);else if(byte===10)encoded.push(92,110);else if(byte===13)encoded.push(92,114);else encoded.push(byte)}res.setHeader('content-type','application/json');res.end(Buffer.concat([Buffer.from('[{"data":"'),Buffer.from(encoded),Buffer.from('"}]')]));return}if(mode==='changed-file'&&own)own['last-modified']='changed';return reply(200,mode==='missing-data'?[{ret:data}]:[{data:['short-chunk','truncated'].includes(mode)?data.slice(0,-1):data}])}
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()))}
try{if(['success','oversize','utf8-boundary'].includes(mode)){assert.equal(await c.exportConfigScript(),content);assert.equal(readCalls,mode==='success'?1:3)}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==='max-size')assert.equal(readCalls,0);if(['short-chunk','missing-data'].includes(mode))assert.equal(readCalls,1)}finally{await new Promise<void>(r=>fixture.close(()=>r()))}
}
console.log('native configuration export: 8 HTTP scenarios passed; original file never deleted')
console.log('native configuration export: 13 HTTP scenarios passed; original file never deleted')