Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor fbd1be8952 test(ipsec): покрыть формы ret и print в декодере файла RouterOS
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m28s
Docker images / frontend-image (push) Successful in 3m4s
Docker images / updater-image (push) Successful in 51s
Docker images / backend-image (push) Successful in 2m25s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 9s
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:01:42 +07:00
DenozordecandCursor 0bf5d6065c fix(ipsec): читать содержимое .p12 из поля ret ответа /file/get
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:01:31 +07:00
2 changed files with 133 additions and 53 deletions
+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 КБ). */
+50 -26
View File
@@ -1,65 +1,89 @@
import assert from "node:assert/strict"
import { extractRosContentsField } from "./mikrotik.js"
/**
* Собирает ответ RouterOS на `/file/get` (.proplist=contents) в том виде, в каком его
* отдаёт устройство: single-byte строка, экранируются только кавычка и обратный слэш.
*/
function rosFileGetResponse(contents: Buffer): Buffer {
/** Экранирование строки ответа RouterOS: только кавычка и обратный слэш. */
function escapeRosString(contents: Buffer): Buffer {
const escaped: number[] = []
for (const byte of contents) {
if (byte === 0x22 || byte === 0x5c) escaped.push(0x5c, byte)
else escaped.push(byte)
}
return Buffer.from(escaped)
}
/** Ответ команды get: `{"ret":"<содержимое>"}` (!done с данными). */
function rosGetResponse(contents: Buffer): Buffer {
return Buffer.concat([
Buffer.from('{"ret":"', "latin1"),
escapeRosString(contents),
Buffer.from('"}', "latin1"),
])
}
/** Ответ команды print: `[{".id":"*A","contents":"<содержимое>"}]` (!re-запись). */
function rosPrintResponse(contents: Buffer): Buffer {
return Buffer.concat([
Buffer.from('[{".id":"*A","contents":"', "latin1"),
Buffer.from(escaped),
escapeRosString(contents),
Buffer.from('"}]', "latin1"),
])
}
{
// ASCII-содержимое
// get: ASCII-содержимое
const content = Buffer.from("hello p12", "latin1")
assert.deepEqual(extractRosContentsField(rosFileGetResponse(content)), content)
assert.deepEqual(extractRosContentsField(rosGetResponse(content)), content)
}
{
// Экранированные кавычки и обратный слэш
// print: ASCII-содержимое (обратная совместимость)
const content = Buffer.from("hello p12", "latin1")
assert.deepEqual(extractRosContentsField(rosPrintResponse(content)), content)
}
{
// get: экранированные кавычки, обратный слэш и \u-escape
const content = Buffer.from('a"b\\c"d', "latin1")
assert.deepEqual(extractRosContentsField(rosFileGetResponse(content)), content)
assert.deepEqual(extractRosContentsField(rosGetResponse(content)), content)
assert.deepEqual(
extractRosContentsField(Buffer.from('{"ret":"a\\nb\\u0041"}', "latin1")),
Buffer.from("a\nbA", "latin1"),
)
}
{
// Пустой файл
assert.equal(extractRosContentsField(rosFileGetResponse(Buffer.alloc(0))).length, 0)
// print: экранированные кавычки, обратный слэш и \u-escape
assert.deepEqual(
extractRosContentsField(Buffer.from('[{"contents":"a\\"b\\\\c\\u0044"}]', "latin1")),
Buffer.from('a"b\\cD', "latin1"),
)
}
{
// Управляющие escape-последовательности
const raw = Buffer.from('{"contents":"a\\nb\\tc\\u0041"}', "latin1")
assert.deepEqual(extractRosContentsField(raw), Buffer.from("a\nb\tcA", "latin1"))
}
{
// contents не первый ключ в объекте
const raw = Buffer.from('{".id":"*B","name":"x.p12","contents":"DATA"}', "latin1")
assert.deepEqual(extractRosContentsField(raw), Buffer.from("DATA", "latin1"))
// Пустой файл — пустой Buffer
assert.equal(extractRosContentsField(rosGetResponse(Buffer.alloc(0))).length, 0)
assert.equal(extractRosContentsField(rosPrintResponse(Buffer.alloc(0))).length, 0)
}
{
// Все 256 байт: single-byte кодировка не должна терять значения 0x80–0xFF и NUL
const content = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
const decoded = extractRosContentsField(rosFileGetResponse(content))
assert.equal(decoded.length, 256)
assert.deepEqual(decoded, content)
for (const raw of [rosGetResponse(content), rosPrintResponse(content)]) {
const decoded = extractRosContentsField(raw)
assert.equal(decoded.length, 256)
assert.deepEqual(decoded, content)
}
}
{
// Нет поля contents — понятная ошибка
// Нет полей ret/contents — понятная ошибка с превью ответа
assert.throws(
() => extractRosContentsField(Buffer.from('{".id":"*A"}', "latin1")),
/не содержит поле contents/,
/не содержит поле ret\/contents/,
)
assert.throws(
() => extractRosContentsField(Buffer.from('{"error":400,"detail":"Bad Request"}', "latin1")),
/Bad Request/,
)
}