fix(ipsec): читать содержимое .p12 из поля ret ответа /file/get
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -271,7 +271,7 @@ function rosDelete(
|
|||||||
/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */
|
/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */
|
||||||
function rosRawRequest(
|
function rosRawRequest(
|
||||||
params: MikrotikConnectParams,
|
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> {
|
): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const basePath = params.apiPath ?? "/rest"
|
const basePath = params.apiPath ?? "/rest"
|
||||||
@@ -402,13 +402,14 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
|||||||
|| entryName.endsWith(`/${base}`)
|
|| entryName.endsWith(`/${base}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Лимит команды `/file/get`: RouterOS отдаёт содержимое файлов не больше 60 KB. */
|
/** Лимит команды `/file/get`: выше 61439 байт RouterOS молча отдаёт 0 байт. */
|
||||||
const MAX_FILE_GET_BYTES = 60 * 1024
|
const MAX_FILE_GET_BYTES = 61439
|
||||||
|
|
||||||
|
const RET_MARKER = Buffer.from('"ret":', "latin1")
|
||||||
const CONTENTS_MARKER = Buffer.from('"contents":', "latin1")
|
const CONTENTS_MARKER = Buffer.from('"contents":', "latin1")
|
||||||
|
|
||||||
function assertRosFileReadable(entry: { name: string; size: number }): void {
|
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(
|
throw new Error(
|
||||||
`Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — `
|
`Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — `
|
||||||
+ "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)",
|
+ "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
|
* RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte
|
||||||
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
|
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
|
||||||
* (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486).
|
* (см. 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
|
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
||||||
*/
|
*/
|
||||||
export function extractRosContentsField(raw: Buffer): Buffer {
|
export function extractRosContentsField(raw: Buffer): Buffer {
|
||||||
const marker = raw.indexOf(CONTENTS_MARKER)
|
const start = findRosStringField(raw, RET_MARKER) ?? findRosStringField(raw, CONTENTS_MARKER)
|
||||||
if (marker < 0) throw new Error("RouterOS: ответ не содержит поле contents")
|
if (start == null) {
|
||||||
|
throw new Error(`ответ не содержит поле ret/contents (${rosResponsePreview(raw)})`)
|
||||||
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 out: number[] = []
|
const out: number[] = []
|
||||||
|
let i = start
|
||||||
while (i < raw.length) {
|
while (i < raw.length) {
|
||||||
const byte = raw[i]
|
const byte = raw[i]
|
||||||
if (byte === 0x22) return Buffer.from(out) // закрывающая кавычка
|
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 0x74: out.push(0x09); break // \t
|
||||||
case 0x75: { // \uXXXX
|
case 0x75: { // \uXXXX
|
||||||
const hex = raw.subarray(i, i + 4).toString("latin1")
|
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
|
i += 4
|
||||||
for (const b of Buffer.from(String.fromCharCode(parseInt(hex, 16)), "utf8")) out.push(b)
|
for (const b of Buffer.from(String.fromCharCode(parseInt(hex, 16)), "utf8")) out.push(b)
|
||||||
break
|
break
|
||||||
@@ -463,7 +475,23 @@ export function extractRosContentsField(raw: Buffer): Buffer {
|
|||||||
default: out.push(esc)
|
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 {
|
function isRosJsonSpace(byte: number): boolean {
|
||||||
@@ -789,25 +817,53 @@ export class MikrotikClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Чтение содержимого файла RouterOS командой `/file/get` (REST: POST).
|
* Чтение содержимого файла RouterOS. Путь `GET /file/<name>` не существует — RouterOS
|
||||||
* Путь `GET /file/<name>` не существует — RouterOS отвечает `no such command prefix`.
|
* отвечает `no such command prefix`; используются команды `get`/`print`.
|
||||||
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
|
||||||
*/
|
*/
|
||||||
private async readFileContents(
|
private async readFileContents(
|
||||||
target: { id?: string; name?: string },
|
target: { id?: string; name?: string },
|
||||||
timeoutMs = 30_000,
|
timeoutMs = 30_000,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const body: Record<string, string> = { ".proplist": "contents" }
|
const readOnce = async (id?: string, name?: string): Promise<Buffer> => {
|
||||||
if (target.id) body[".id"] = target.id
|
const strategies: Array<{ path: string; body: Record<string, string | string[]> }> = []
|
||||||
else if (target.name) body.name = target.name
|
if (id) {
|
||||||
else throw new Error("RouterOS: не задан файл для чтения")
|
strategies.push({ path: "/file/get", body: { ".id": id, "value-name": "contents" } })
|
||||||
const raw = await rosRawRequest(this.params, {
|
strategies.push({ path: "/file/get", body: { ".id": id, ".proplist": "contents" } })
|
||||||
method: "POST",
|
}
|
||||||
path: "/file/get",
|
if (name) {
|
||||||
body,
|
strategies.push({ path: "/file/print", body: { ".proplist": "contents", ".query": [`name=${name}`] } })
|
||||||
timeoutMs,
|
}
|
||||||
})
|
if (strategies.length === 0) throw new Error("не задан файл для чтения")
|
||||||
return extractRosContentsField(raw)
|
|
||||||
|
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 КБ). */
|
/** Скачивание содержимого файла RouterOS: `/file/get` + побайтовый разбор (лимит 60 КБ). */
|
||||||
|
|||||||
Reference in New Issue
Block a user