fix(ipsec): читать содержимое .p12 из поля ret ответа /file/get

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-09-13 01:01:31 +07:00
co-authored by Cursor
parent 67c3a4fd73
commit 0bf5d6065c
+83 -27
View File
@@ -271,7 +271,7 @@ function rosDelete(
/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */
function rosRawRequest(
params: MikrotikConnectParams,
opts: { method: "GET" | "POST"; path: string; body?: Record<string, string>; timeoutMs: number },
opts: { method: "GET" | "POST"; path: string; body?: Record<string, string | string[]>; timeoutMs: number },
): Promise<Buffer> {
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/<name>` не существует — RouterOS отвечает `no such command prefix`.
* Чтение содержимого файла RouterOS. Путь `GET /file/<name>` не существует — 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<Buffer> {
const body: Record<string, string> = { ".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<Buffer> => {
const strategies: Array<{ path: string; body: Record<string, string | string[]> }> = []
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 КБ). */