Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e7437210 | ||
|
|
e0364d45df | ||
|
|
bbff586da2 | ||
|
|
fbd1be8952 | ||
|
|
0bf5d6065c |
@@ -101,6 +101,10 @@ import { identityRosBody } from "./ipsec-ros.js"
|
||||
assert.equal(remote.addr, "vpn.example.com")
|
||||
assert.equal(remote.id, "vpn.example.com")
|
||||
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)
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
name: string
|
||||
serverEndpoint: string
|
||||
@@ -269,8 +275,6 @@ export function buildSswanConfig(args: {
|
||||
type: "ikev2-cert",
|
||||
remote: { addr: args.serverEndpoint, id: args.serverId },
|
||||
local: { p12: args.p12B64 },
|
||||
"ike-proposal": "AES256-SHA256-MODP2048",
|
||||
"esp-proposal": "AES256-SHA256-MODP2048",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -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 КБ). */
|
||||
|
||||
@@ -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/,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ function IpsecCertSheet({
|
||||
</SheetDescription>
|
||||
</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 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Бандл сертификата пуст — перезапустите экспорт с новой парольной фразой.
|
||||
@@ -119,7 +119,7 @@ function IpsecCertSheet({
|
||||
</div>
|
||||
</FormField>
|
||||
{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>
|
||||
</p>
|
||||
) : null}
|
||||
@@ -129,7 +129,7 @@ function IpsecCertSheet({
|
||||
<SectionTitle>Файлы</SectionTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle) return
|
||||
@@ -138,12 +138,12 @@ function IpsecCertSheet({
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{bundle.filename} (.p12, сертификат + ключ)
|
||||
<span className="truncate">{bundle.filename} (.p12, сертификат + ключ)</span>
|
||||
</Button>
|
||||
{bundle.sswanContent && bundle.sswanFilename ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
className="justify-start min-w-0"
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
if (!bundle.sswanContent || !bundle.sswanFilename) return
|
||||
@@ -152,7 +152,7 @@ function IpsecCertSheet({
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{bundle.sswanFilename} (strongSwan)
|
||||
<span className="truncate">{bundle.sswanFilename} (strongSwan)</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{bundle.instructions ? (
|
||||
@@ -172,7 +172,7 @@ function IpsecCertSheet({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user