From 0bf5d6065ce4fa5e9f5aef996ae0f6d3fc983ee8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 13 Sep 2026 01:01:31 +0700 Subject: [PATCH] =?UTF-8?q?fix(ipsec):=20=D1=87=D0=B8=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D0=B5=20.p12=20=D0=B8=D0=B7=20=D0=BF=D0=BE=D0=BB=D1=8F=20ret?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20/file/get?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/src/services/mikrotik.ts | 110 +++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 27 deletions(-) diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 4b0a7df..174a3cb 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -271,7 +271,7 @@ function rosDelete( /** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */ function rosRawRequest( params: MikrotikConnectParams, - opts: { method: "GET" | "POST"; path: string; body?: Record; timeoutMs: number }, + opts: { method: "GET" | "POST"; path: string; body?: Record; timeoutMs: number }, ): Promise { return new Promise((resolve, reject) => { const basePath = params.apiPath ?? "/rest" @@ -402,13 +402,14 @@ function matchesUploadedFile(entryName: string, requested: string): boolean { || entryName.endsWith(`/${base}`) } -/** Лимит команды `/file/get`: RouterOS отдаёт содержимое файлов не больше 60 KB. */ -const MAX_FILE_GET_BYTES = 60 * 1024 +/** Лимит команды `/file/get`: выше 61439 байт RouterOS молча отдаёт 0 байт. */ +const MAX_FILE_GET_BYTES = 61439 +const RET_MARKER = Buffer.from('"ret":', "latin1") const CONTENTS_MARKER = Buffer.from('"contents":', "latin1") function assertRosFileReadable(entry: { name: string; size: number }): void { - if (entry.size > MAX_FILE_GET_BYTES) { + if (entry.size >= MAX_FILE_GET_BYTES) { throw new Error( `Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — ` + "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)", @@ -416,23 +417,34 @@ function assertRosFileReadable(entry: { name: string; size: number }): void { } } +/** Превью ответа RouterOS для диагностики: непечатаемые байты → `\uXXXX`. */ +function rosResponsePreview(raw: Buffer, limit = 200): string { + const slice = raw.subarray(0, limit) + let out = "" + for (const byte of slice) { + if (byte >= 0x20 && byte < 0x7f) out += String.fromCharCode(byte) + else out += `\\u${byte.toString(16).padStart(4, "0")}` + } + return out +} + /** * RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte * кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает * (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486). - * Поле `contents` извлекаем напрямую из сырых байтов, без парсинга всего ответа. + * Значение извлекаем напрямую из сырых байтов, без парсинга всего ответа. + * Команда `get` кладёт результат в поле `ret` (`!done=ret=...`), команда `print` — в `contents`. + * @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 marker = raw.indexOf(CONTENTS_MARKER) - if (marker < 0) throw new Error("RouterOS: ответ не содержит поле contents") - - let i = marker + CONTENTS_MARKER.length - while (i < raw.length && isRosJsonSpace(raw[i])) i += 1 - if (raw[i] !== 0x22) throw new Error("RouterOS: поле contents не является строкой") - i += 1 + const start = findRosStringField(raw, RET_MARKER) ?? findRosStringField(raw, CONTENTS_MARKER) + if (start == null) { + throw new Error(`ответ не содержит поле ret/contents (${rosResponsePreview(raw)})`) + } const out: number[] = [] + let i = start while (i < raw.length) { const byte = raw[i] if (byte === 0x22) return Buffer.from(out) // закрывающая кавычка @@ -455,7 +467,7 @@ export function extractRosContentsField(raw: Buffer): Buffer { case 0x74: out.push(0x09); break // \t case 0x75: { // \uXXXX const hex = raw.subarray(i, i + 4).toString("latin1") - if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("RouterOS: некорректный \\u-escape в contents") + if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("некорректный \\u-escape в содержимом файла") i += 4 for (const b of Buffer.from(String.fromCharCode(parseInt(hex, 16)), "utf8")) out.push(b) break @@ -463,7 +475,23 @@ export function extractRosContentsField(raw: Buffer): Buffer { default: out.push(esc) } } - throw new Error("RouterOS: строка contents не закрыта") + throw new Error("строка содержимого файла не закрыта") +} + +/** + * Ищет ключ JSON и возвращает индекс первого байта строкового значения (после открывающей кавычки). + * Возвращает null, если ключа нет или за ним не строка. + */ +function findRosStringField(raw: Buffer, marker: Buffer): number | null { + let from = 0 + for (;;) { + const at = raw.indexOf(marker, from) + if (at < 0) return null + let i = at + marker.length + while (i < raw.length && isRosJsonSpace(raw[i])) i += 1 + if (raw[i] === 0x22) return i + 1 + from = at + marker.length + } } function isRosJsonSpace(byte: number): boolean { @@ -789,25 +817,53 @@ export class MikrotikClient { } /** - * Чтение содержимого файла RouterOS командой `/file/get` (REST: POST). - * Путь `GET /file/` не существует — RouterOS отвечает `no such command prefix`. + * Чтение содержимого файла RouterOS. Путь `GET /file/` не существует — RouterOS + * отвечает `no such command prefix`; используются команды `get`/`print`. * @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files */ private async readFileContents( target: { id?: string; name?: string }, timeoutMs = 30_000, ): Promise { - const body: Record = { ".proplist": "contents" } - if (target.id) body[".id"] = target.id - else if (target.name) body.name = target.name - else throw new Error("RouterOS: не задан файл для чтения") - const raw = await rosRawRequest(this.params, { - method: "POST", - path: "/file/get", - body, - timeoutMs, - }) - return extractRosContentsField(raw) + const readOnce = async (id?: string, name?: string): Promise => { + const strategies: Array<{ path: string; body: Record }> = [] + if (id) { + strategies.push({ path: "/file/get", body: { ".id": id, "value-name": "contents" } }) + strategies.push({ path: "/file/get", body: { ".id": id, ".proplist": "contents" } }) + } + if (name) { + strategies.push({ path: "/file/print", body: { ".proplist": "contents", ".query": [`name=${name}`] } }) + } + if (strategies.length === 0) throw new Error("не задан файл для чтения") + + let lastError: unknown + for (const strategy of strategies) { + try { + const raw = await rosRawRequest(this.params, { + method: "POST", + path: strategy.path, + body: strategy.body, + timeoutMs, + }) + const content = extractRosContentsField(raw) + if (content.length > 0) return content + lastError = new Error("RouterOS вернул пустое содержимое файла") + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error ? lastError : new Error("не удалось прочитать содержимое файла") + } + + try { + return await readOnce(target.id, target.name) + } catch (error) { + // `.id` мог устареть (файл пересоздан) — пробуем один раз найти его заново по имени. + if (!target.name) throw error + const fresh = await this.findFileEntry(target.name) + if (!fresh || fresh.id === target.id) throw error + return readOnce(fresh.id, fresh.name) + } } /** Скачивание содержимого файла RouterOS: `/file/get` + побайтовый разбор (лимит 60 КБ). */