Compare commits

..
9 Commits
Author SHA1 Message Date
DenozordecandCursor f8e7437210 fix(ui): убрать горизонтальный скролл в шторке сертификата
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-test (push) Successful in 1m56s
Docker images / frontend-image (push) Successful in 3m9s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m46s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:22:36 +07:00
DenozordecandCursor e0364d45df test(ipsec): закрепить отсутствие proposal-ключей в .sswan
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:22:36 +07:00
DenozordecandCursor bbff586da2 fix(ipsec): убрать невалидный ike-proposal из .sswan-профиля
Co-authored-by: Cursor <[email protected]>
2026-09-13 01:22:36 +07:00
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
DenozordecandCursor 67c3a4fd73 test(ipsec): покрыть декодер содержимого файла RouterOS
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-test (push) Successful in 1m53s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 3m19s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:44:53 +07:00
DenozordecandCursor 9beb24273b fix(ipsec): скачивать .p12 через /file/get вместо несуществующего REST-пути
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:44:44 +07:00
DenozordecandCursor 5cc6128086 fix(ipsec): уточнить подсказку про политику sensitive у группы пользователя
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 3m13s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 7s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:15:33 +07:00
DenozordecandCursor 569b6be2b4 fix(ipsec): не блокироваться на пароле .p12 и подсказать политику sensitive
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <[email protected]>
2026-09-13 00:15:05 +07:00
8 changed files with 371 additions and 59 deletions
+2 -1
View File
@@ -20,9 +20,10 @@
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts", "test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync", "test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts" "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
}, },
"dependencies": { "dependencies": {
+6 -6
View File
@@ -139,7 +139,7 @@ async function buildCertBundle(
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string }, args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
): Promise<IpsecCertBundle> { ): Promise<IpsecCertBundle> {
const certName = args.certName?.trim() || clientCertName(args.userName) const certName = args.certName?.trim() || clientCertName(args.userName)
const { fileName, content } = await exportCertificateP12ByName(client, certName, args.passphrase) const { fileName, content, passphrase } = await exportCertificateP12ByName(client, certName, args.passphrase)
const p12B64 = content.toString("base64") const p12B64 = content.toString("base64")
return { return {
user: args.userName, user: args.userName,
@@ -147,7 +147,7 @@ async function buildCertBundle(
filename: fileName, filename: fileName,
contentB64: p12B64, contentB64: p12B64,
mime: "application/x-pkcs12", mime: "application/x-pkcs12",
passphrase: args.passphrase, passphrase,
sswanFilename: `${certName}.sswan`, sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({ sswanContent: buildSswanConfig({
name: `IKEv2 ${args.serverEndpoint}`, name: `IKEv2 ${args.serverEndpoint}`,
@@ -159,7 +159,7 @@ async function buildCertBundle(
userName: args.userName, userName: args.userName,
serverEndpoint: args.serverEndpoint, serverEndpoint: args.serverEndpoint,
p12Filename: fileName, p12Filename: fileName,
passphrase: args.passphrase, passphrase,
dns: args.dns, dns: args.dns,
}), }),
} }
@@ -646,7 +646,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const dns = sharedMc?.["static-dns"]?.trim() || undefined const dns = sharedMc?.["static-dns"]?.trim() || undefined
// export работает по имени сертификата (certName), не по userName // export работает по имени сертификата (certName), не по userName
const { fileName, content } = await client.exportCertificatePkcs12({ const { fileName, content, passphrase } = await client.exportCertificatePkcs12({
name: certName, name: certName,
passphrase: body.passphrase, passphrase: body.passphrase,
}) })
@@ -657,7 +657,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
filename: fileName, filename: fileName,
contentB64: p12B64, contentB64: p12B64,
mime: "application/x-pkcs12", mime: "application/x-pkcs12",
passphrase: body.passphrase, passphrase,
sswanFilename: `${certName}.sswan`, sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({ sswanContent: buildSswanConfig({
name: `IKEv2 ${serverEndpoint}`, name: `IKEv2 ${serverEndpoint}`,
@@ -669,7 +669,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
userName, userName,
serverEndpoint, serverEndpoint,
p12Filename: fileName, p12Filename: fileName,
passphrase: body.passphrase, passphrase,
dns, dns,
}), }),
} }
+4 -4
View File
@@ -112,12 +112,12 @@ export async function exportCertificateP12ByName(
client: MikrotikClient, client: MikrotikClient,
certName: string, certName: string,
passphrase: string, passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> { ): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
const name = certName.trim() const name = certName.trim()
const cert = await findCertificate(client, name) const cert = await findCertificate(client, name)
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`) if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase }) const { fileName, content, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase })
return { fileName, content, certName: name } return { fileName, content, certName: name, passphrase: effective }
} }
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */ /** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
@@ -125,6 +125,6 @@ export async function exportClientP12(
client: MikrotikClient, client: MikrotikClient,
userName: string, userName: string,
passphrase: string, passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> { ): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
return exportCertificateP12ByName(client, clientCertName(userName), passphrase) return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
} }
@@ -101,6 +101,10 @@ import { identityRosBody } from "./ipsec-ros.js"
assert.equal(remote.addr, "vpn.example.com") assert.equal(remote.addr, "vpn.example.com")
assert.equal(remote.id, "vpn.example.com") assert.equal(remote.id, "vpn.example.com")
assert.equal(local.p12, "cDEy") assert.equal(local.p12, "cDEy")
// ike-proposal/esp-proposal не задаём: верхний регистр strongSwan не парсит,
// а его defaults совместимы с профилем MikroTik.
assert.equal("ike-proposal" in parsed, false)
assert.equal("esp-proposal" in parsed, false)
} }
{ {
+7 -3
View File
@@ -253,7 +253,13 @@ export function findFreePoolIp(range: string, taken: Iterable<string>): string |
// ── клиентские конфиги ────────────────────────────────────────────────────── // ── клиентские конфиги ──────────────────────────────────────────────────────
/** strongSwan (Android/iOS) .sswan-профиль с встроенным .p12. */ /**
* strongSwan Android .sswan-профиль с встроенным .p12.
* `ike-proposal`/`esp-proposal` намеренно не задаём: приложение берёт свои defaults,
* которые пересекаются с профилем MikroTik (aes256-sha256-modp2048); фиксированный
* неполный список лишь сужает совместимость.
* @see https://docs.strongswan.org/docs/latest/os/androidVpnClientProfiles.html
*/
export function buildSswanConfig(args: { export function buildSswanConfig(args: {
name: string name: string
serverEndpoint: string serverEndpoint: string
@@ -269,8 +275,6 @@ export function buildSswanConfig(args: {
type: "ikev2-cert", type: "ikev2-cert",
remote: { addr: args.serverEndpoint, id: args.serverId }, remote: { addr: args.serverEndpoint, id: args.serverId },
local: { p12: args.p12B64 }, local: { p12: args.p12B64 },
"ike-proposal": "AES256-SHA256-MODP2048",
"esp-proposal": "AES256-SHA256-MODP2048",
}, },
null, null,
2, 2,
+251 -38
View File
@@ -1,5 +1,6 @@
import http from "node:http" import http from "node:http"
import https from "node:https" import https from "node:https"
import { randomBytes } from "node:crypto"
import type { Server } from "../db/schema.js" import type { Server } from "../db/schema.js"
import { parseRosDataSizeBytes } from "./ros-metric-parse.js" import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
import type { import type {
@@ -267,22 +268,27 @@ function rosDelete(
}) })
} }
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */ /** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов. */
function rosDownload( function rosRawRequest(
params: MikrotikConnectParams, params: MikrotikConnectParams,
path: string, opts: { method: "GET" | "POST"; path: string; body?: Record<string, string | string[]>; timeoutMs: number },
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"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = opts.body ? JSON.stringify(opts.body) : undefined
const options: https.RequestOptions = { const options: https.RequestOptions = {
hostname: params.host, hostname: params.host,
port: params.port, port: params.port,
path: basePath + path, path: basePath + opts.path,
method: "GET", method: opts.method,
headers: { Authorization: authHeader }, headers: {
Authorization: authHeader,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
} }
@@ -294,12 +300,7 @@ function rosDownload(
res.on("end", () => { res.on("end", () => {
const buf = Buffer.concat(chunks) const buf = Buffer.concat(chunks)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200))) reject(new MikrotikError(res.statusCode ?? 0, opts.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}`))
return return
} }
resolve(buf) resolve(buf)
@@ -307,13 +308,14 @@ function rosDownload(
}) })
const timer = setTimeout(() => { const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${opts.timeoutMs / 1000}s`))
}, timeoutMs) }, opts.timeoutMs)
req.on("close", () => clearTimeout(timer)) req.on("close", () => clearTimeout(timer))
req.on("error", (err) => { req.on("error", (err) => {
clearTimeout(timer) clearTimeout(timer)
reject(err) reject(err)
}) })
if (payload) req.write(payload)
req.end() req.end()
}) })
} }
@@ -400,6 +402,102 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName.endsWith(`/${base}`) || entryName.endsWith(`/${base}`)
} }
/** Лимит команды `/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) {
throw new Error(
`Файл ${routerFileBasename(entry.name)} больше ${MAX_FILE_GET_BYTES / 1024} КБ — `
+ "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)",
)
}
}
/** Превью ответа 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).
* Значение извлекаем напрямую из сырых байтов, без парсинга всего ответа.
* Команда `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 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) // закрывающая кавычка
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("некорректный \\u-escape в содержимом файла")
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("строка содержимого файла не закрыта")
}
/**
* Ищет ключ 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 {
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d
}
export function firewallRestPath( export function firewallRestPath(
family: FirewallFamily, family: FirewallFamily,
table: FirewallTable | "address-list", table: FirewallTable | "address-list",
@@ -422,6 +520,31 @@ function unknownParameterName(error: MikrotikError): string | undefined {
return match?.[1] return match?.[1]
} }
/**
* RouterOS отклоняет `export-passphrase` короче 8 символов:
* `Failure: If used, passphrase must be at least 8 chars long!`.
* Та же ошибка приходит, если политика `sensitive` не даёт применить sensitive-параметр.
*/
export function isPassphraseTooShortError(error: unknown): boolean {
return (
error instanceof MikrotikError &&
error.statusCode === 400 &&
/passphrase/i.test(error.body) &&
/at least\s*8/i.test(error.body)
)
}
/** Пароль .p12, который RouterOS примет гарантированно (≥8 символов). */
function generateExportPassphrase(): string {
return randomBytes(12).toString("base64url")
}
const EXPORT_PASSPHRASE_HINT =
"RouterOS отклонил пароль .p12. Экспорт приватного ключа (export-passphrase — sensitive-параметр) " +
"разрешён только пользователю, у которого в политике группы есть «sensitive». " +
"Проверьте: /user print → группа API-пользователя → /user group print; добавьте sensitive в policy группы " +
"(в группе full она уже есть)."
export class MikrotikClient { export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {} constructor(private readonly params: MikrotikConnectParams) {}
@@ -599,13 +722,27 @@ export class MikrotikClient {
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object") return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
} }
async listFiles(): Promise<Array<{ name: string }>> { async listFiles(): Promise<Array<{ id: string; name: string; size: number }>> {
const raw = await this.get<unknown>("/file") const raw = await this.get<unknown>("/file")
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
return raw return raw
.filter((row): row is Record<string, unknown> => row != null && typeof row === "object") .filter((row): row is Record<string, unknown> => row != null && typeof row === "object")
.map((row) => ({ name: String(row.name ?? "") })) .map((row) => ({
.filter((row) => row.name.length > 0) 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/<base>`. */
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<string> { private async resolveUploadedFileName(requested: string): Promise<string> {
@@ -679,21 +816,76 @@ export class MikrotikClient {
} }
} }
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */ /**
async downloadFile(fileName: string, timeoutMs = 30_000): Promise<Buffer> { * Чтение содержимого файла RouterOS. Путь `GET /file/<name>` не существует — RouterOS
const normalized = routerFileBasename(fileName) * отвечает `no such command prefix`; используются команды `get`/`print`.
const candidates = [normalized, `flash/${normalized}`] * @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
let lastError: unknown */
for (const name of candidates) { private async readFileContents(
try { target: { id?: string; name?: string },
return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs) timeoutMs = 30_000,
} catch (error) { ): Promise<Buffer> {
lastError = error 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("не удалось прочитать содержимое файла")
} }
throw lastError instanceof Error
? lastError try {
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`) 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 КБ). */
async downloadFile(
target: { name: string; id?: string; size?: number },
timeoutMs = 30_000,
): Promise<Buffer> {
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<void> {
const id = idOrName.startsWith("*")
? idOrName
: (await this.findFileEntry(idOrName))?.id
if (!id) return
await this.delete(`/file/${encodeURIComponent(id)}`, timeoutMs)
} }
/** /**
@@ -745,12 +937,12 @@ export class MikrotikClient {
} }
} }
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */ /** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает созданный файл. */
async exportCertificate(params: { async exportCertificate(params: {
name: string name: string
type: "pkcs12" | "pem" type: "pkcs12" | "pem"
passphrase?: string passphrase?: string
}, timeoutMs = 60_000): Promise<string> { }, timeoutMs = 60_000): Promise<{ fileName: string; fileId: string; size: number }> {
const body: Record<string, string> = { name: params.name, type: params.type } const body: Record<string, string> = { name: params.name, type: params.type }
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim() if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
let raw: unknown let raw: unknown
@@ -770,14 +962,35 @@ export class MikrotikClient {
const hit = files.find((f) => wanted.includes(f.name)) const hit = files.find((f) => wanted.includes(f.name))
?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name)) ?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name))
if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`) if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`)
return hit.name return { fileName: hit.name, fileId: hit.id, size: hit.size }
} }
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */ /** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> { async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> {
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase }) let passphrase = params.passphrase?.trim() || generateExportPassphrase()
const content = await this.downloadFile(fileName) let exported: { fileName: string; fileId: string; size: number }
return { fileName, content } try {
exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase })
} catch (error) {
if (!isPassphraseTooShortError(error)) throw error
// RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл.
const forced = generateExportPassphrase()
try {
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({
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<void> { async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import { extractRosContentsField } from "./mikrotik.js"
/** Экранирование строки ответа 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"),
escapeRosString(contents),
Buffer.from('"}]', "latin1"),
])
}
{
// get: ASCII-содержимое
const content = Buffer.from("hello p12", "latin1")
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(rosGetResponse(content)), content)
assert.deepEqual(
extractRosContentsField(Buffer.from('{"ret":"a\\nb\\u0041"}', "latin1")),
Buffer.from("a\nbA", "latin1"),
)
}
{
// print: экранированные кавычки, обратный слэш и \u-escape
assert.deepEqual(
extractRosContentsField(Buffer.from('[{"contents":"a\\"b\\\\c\\u0044"}]', "latin1")),
Buffer.from('a"b\\cD', "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))
for (const raw of [rosGetResponse(content), rosPrintResponse(content)]) {
const decoded = extractRosContentsField(raw)
assert.equal(decoded.length, 256)
assert.deepEqual(decoded, content)
}
}
{
// Нет полей ret/contents — понятная ошибка с превью ответа
assert.throws(
() => extractRosContentsField(Buffer.from('{".id":"*A"}', "latin1")),
/не содержит поле ret\/contents/,
)
assert.throws(
() => extractRosContentsField(Buffer.from('{"error":400,"detail":"Bad Request"}', "latin1")),
/Bad Request/,
)
}
console.log("ros-file-contents.test.ts: ok")
+7 -7
View File
@@ -78,7 +78,7 @@ function IpsecCertSheet({
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5"> <div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
{!bundle ? ( {!bundle ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Бандл сертификата пуст перезапустите экспорт с новой парольной фразой. Бандл сертификата пуст перезапустите экспорт с новой парольной фразой.
@@ -119,7 +119,7 @@ function IpsecCertSheet({
</div> </div>
</FormField> </FormField>
{bundle.serverEndpoint ? ( {bundle.serverEndpoint ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground break-all">
Сервер: <span className="font-mono">{bundle.serverEndpoint}</span> Сервер: <span className="font-mono">{bundle.serverEndpoint}</span>
</p> </p>
) : null} ) : null}
@@ -129,7 +129,7 @@ function IpsecCertSheet({
<SectionTitle>Файлы</SectionTitle> <SectionTitle>Файлы</SectionTitle>
<Button <Button
variant="outline" variant="outline"
className="justify-start" className="justify-start min-w-0"
disabled={!canDownload} disabled={!canDownload}
onClick={() => { onClick={() => {
if (!bundle) return if (!bundle) return
@@ -138,12 +138,12 @@ function IpsecCertSheet({
}} }}
> >
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
{bundle.filename} (.p12, сертификат + ключ) <span className="truncate">{bundle.filename} (.p12, сертификат + ключ)</span>
</Button> </Button>
{bundle.sswanContent && bundle.sswanFilename ? ( {bundle.sswanContent && bundle.sswanFilename ? (
<Button <Button
variant="outline" variant="outline"
className="justify-start" className="justify-start min-w-0"
disabled={!canDownload} disabled={!canDownload}
onClick={() => { onClick={() => {
if (!bundle.sswanContent || !bundle.sswanFilename) return if (!bundle.sswanContent || !bundle.sswanFilename) return
@@ -152,7 +152,7 @@ function IpsecCertSheet({
}} }}
> >
<DownloadIcon className="size-4" /> <DownloadIcon className="size-4" />
{bundle.sswanFilename} (strongSwan) <span className="truncate">{bundle.sswanFilename} (strongSwan)</span>
</Button> </Button>
) : null} ) : null}
{bundle.instructions ? ( {bundle.instructions ? (
@@ -172,7 +172,7 @@ function IpsecCertSheet({
</div> </div>
{bundle.instructions ? ( {bundle.instructions ? (
<pre className="max-h-72 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap"> <pre className="max-h-72 overflow-y-auto overflow-x-hidden rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-words">
{bundle.instructions} {bundle.instructions}
</pre> </pre>
) : null} ) : null}