From 9beb24273b60a4cdaea78de19b6a97726823e547 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 13 Sep 2026 00:44:44 +0700 Subject: [PATCH] =?UTF-8?q?fix(ipsec):=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20.p12=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20/file/get=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D1=8E=D1=89=D0=B5=D0=B3=D0=BE=20REST-=D0=BF=D1=83=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/src/services/mikrotik.ts | 198 ++++++++++++++++++++++++------- 1 file changed, 157 insertions(+), 41 deletions(-) diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index e713a8b..4b0a7df 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -268,22 +268,27 @@ function rosDelete( }) } -/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */ -function rosDownload( +/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */ +function rosRawRequest( params: MikrotikConnectParams, - path: string, - timeoutMs: number, + opts: { method: "GET" | "POST"; path: string; body?: Record; timeoutMs: number }, ): Promise { return new Promise((resolve, reject) => { const basePath = params.apiPath ?? "/rest" const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") + const payload = opts.body ? JSON.stringify(opts.body) : undefined const options: https.RequestOptions = { hostname: params.host, port: params.port, - path: basePath + path, - method: "GET", - headers: { Authorization: authHeader }, + path: basePath + opts.path, + method: opts.method, + headers: { + Authorization: authHeader, + ...(payload + ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } + : {}), + }, rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, } @@ -295,12 +300,7 @@ function rosDownload( res.on("end", () => { const buf = Buffer.concat(chunks) if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { - reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200))) - return - } - const contentType = String(res.headers["content-type"] ?? "") - if (contentType.includes("application/json")) { - reject(new Error(`RouterOS вернул метаданные вместо содержимого файла ${path}`)) + reject(new MikrotikError(res.statusCode ?? 0, opts.path, buf.toString("utf8").slice(0, 200))) return } resolve(buf) @@ -308,13 +308,14 @@ function rosDownload( }) const timer = setTimeout(() => { - req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) - }, timeoutMs) + req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${opts.timeoutMs / 1000}s`)) + }, opts.timeoutMs) req.on("close", () => clearTimeout(timer)) req.on("error", (err) => { clearTimeout(timer) reject(err) }) + if (payload) req.write(payload) req.end() }) } @@ -401,6 +402,74 @@ function matchesUploadedFile(entryName: string, requested: string): boolean { || entryName.endsWith(`/${base}`) } +/** Лимит команды `/file/get`: RouterOS отдаёт содержимое файлов не больше 60 KB. */ +const MAX_FILE_GET_BYTES = 60 * 1024 + +const CONTENTS_MARKER = Buffer.from('"contents":', "latin1") + +function assertRosFileReadable(entry: { name: string; size: number }): void { + if (entry.size > MAX_FILE_GET_BYTES) { + throw new Error( + `Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — ` + + "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)", + ) + } +} + +/** + * RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte + * кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает + * (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486). + * Поле `contents` извлекаем напрямую из сырых байтов, без парсинга всего ответа. + * @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 out: number[] = [] + while (i < raw.length) { + const byte = raw[i] + if (byte === 0x22) return Buffer.from(out) // закрывающая кавычка + if (byte !== 0x5c) { // обычный байт + out.push(byte) + i += 1 + continue + } + const esc = raw[i + 1] + if (esc === undefined) break + i += 2 + switch (esc) { + case 0x22: out.push(0x22); break // \" + case 0x5c: out.push(0x5c); break // \\ + case 0x2f: out.push(0x2f); break // \/ + case 0x62: out.push(0x08); break // \b + case 0x66: out.push(0x0c); break // \f + case 0x6e: out.push(0x0a); break // \n + case 0x72: out.push(0x0d); break // \r + 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") + i += 4 + for (const b of Buffer.from(String.fromCharCode(parseInt(hex, 16)), "utf8")) out.push(b) + break + } + default: out.push(esc) + } + } + throw new Error("RouterOS: строка contents не закрыта") +} + +function isRosJsonSpace(byte: number): boolean { + return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d +} + export function firewallRestPath( family: FirewallFamily, table: FirewallTable | "address-list", @@ -625,13 +694,27 @@ export class MikrotikClient { return raw.filter((row): row is Record => row != null && typeof row === "object") } - async listFiles(): Promise> { + async listFiles(): Promise> { const raw = await this.get("/file") if (!Array.isArray(raw)) return [] return raw .filter((row): row is Record => row != null && typeof row === "object") - .map((row) => ({ name: String(row.name ?? "") })) - .filter((row) => row.name.length > 0) + .map((row) => ({ + id: String(row[".id"] ?? ""), + name: String(row.name ?? ""), + size: Number(row.size ?? 0), + })) + .filter((row) => row.name.length > 0 && row.id.length > 0) + } + + /** Поиск файла в `/file` по полному имени, базовому имени или `flash/`. */ + private async findFileEntry(name: string): Promise<{ id: string; name: string; size: number } | undefined> { + const base = routerFileBasename(name) + const files = await this.listFiles() + return files.find((file) => file.name === name) + ?? files.find((file) => file.name === base) + ?? files.find((file) => file.name === `flash/${base}`) + ?? files.find((file) => routerFileBasename(file.name) === base) } private async resolveUploadedFileName(requested: string): Promise { @@ -705,21 +788,48 @@ export class MikrotikClient { } } - /** Скачивание содержимого файла RouterOS (GET /rest/file/, бинарно). */ - async downloadFile(fileName: string, timeoutMs = 30_000): Promise { - const normalized = routerFileBasename(fileName) - const candidates = [normalized, `flash/${normalized}`] - let lastError: unknown - for (const name of candidates) { - try { - return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs) - } catch (error) { - lastError = error - } - } - throw lastError instanceof Error - ? lastError - : new Error(`Не удалось скачать файл ${normalized} с RouterOS`) + /** + * Чтение содержимого файла RouterOS командой `/file/get` (REST: POST). + * Путь `GET /file/` не существует — RouterOS отвечает `no such command prefix`. + * @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) + } + + /** Скачивание содержимого файла RouterOS: `/file/get` + побайтовый разбор (лимит 60 КБ). */ + async downloadFile( + target: { name: string; id?: string; size?: number }, + timeoutMs = 30_000, + ): Promise { + let entry = target.id + ? { id: target.id, name: target.name, size: target.size ?? 0 } + : await this.findFileEntry(target.name) + if (!entry) throw new Error(`Файл ${routerFileBasename(target.name)} не найден на RouterOS`) + assertRosFileReadable(entry) + return this.readFileContents({ id: entry.id, name: entry.name }, timeoutMs) + } + + /** Удаление файла RouterOS по `.id` или имени (для временных артефактов экспорта). */ + async removeFile(idOrName: string, timeoutMs = 10_000): Promise { + const id = idOrName.startsWith("*") + ? idOrName + : (await this.findFileEntry(idOrName))?.id + if (!id) return + await this.delete(`/file/${encodeURIComponent(id)}`, timeoutMs) } /** @@ -771,12 +881,12 @@ export class MikrotikClient { } } - /** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */ + /** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает созданный файл. */ async exportCertificate(params: { name: string type: "pkcs12" | "pem" passphrase?: string - }, timeoutMs = 60_000): Promise { + }, timeoutMs = 60_000): Promise<{ fileName: string; fileId: string; size: number }> { const body: Record = { name: params.name, type: params.type } if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim() let raw: unknown @@ -796,29 +906,35 @@ export class MikrotikClient { const hit = files.find((f) => wanted.includes(f.name)) ?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name)) if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`) - return hit.name + return { fileName: hit.name, fileId: hit.id, size: hit.size } } /** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */ async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> { let passphrase = params.passphrase?.trim() || generateExportPassphrase() - let fileName: string + let exported: { fileName: string; fileId: string; size: number } try { - fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase }) + exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase }) } catch (error) { if (!isPassphraseTooShortError(error)) throw error // RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл. const forced = generateExportPassphrase() try { - fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: forced }) + exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: forced }) passphrase = forced } catch (retryError) { if (isPassphraseTooShortError(retryError)) throw new Error(EXPORT_PASSPHRASE_HINT) throw retryError } } - const content = await this.downloadFile(fileName) - return { fileName, content, passphrase } + const content = await this.downloadFile({ + name: exported.fileName, + id: exported.fileId, + size: exported.size, + }) + // Временный файл экспорта на роутере больше не нужен (best-effort, не влияет на результат). + await this.removeFile(exported.fileId).catch(() => undefined) + return { fileName: exported.fileName, content, passphrase } } async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise {