fix(ipsec): скачивать .p12 через /file/get вместо несуществующего REST-пути
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -268,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>; 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,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)
|
||||||
@@ -308,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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -401,6 +402,74 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
|||||||
|| entryName.endsWith(`/${base}`)
|
|| 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(
|
export function firewallRestPath(
|
||||||
family: FirewallFamily,
|
family: FirewallFamily,
|
||||||
table: FirewallTable | "address-list",
|
table: FirewallTable | "address-list",
|
||||||
@@ -625,13 +694,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> {
|
||||||
@@ -705,21 +788,48 @@ export class MikrotikClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */
|
/**
|
||||||
async downloadFile(fileName: string, timeoutMs = 30_000): Promise<Buffer> {
|
* Чтение содержимого файла RouterOS командой `/file/get` (REST: POST).
|
||||||
const normalized = routerFileBasename(fileName)
|
* Путь `GET /file/<name>` не существует — RouterOS отвечает `no such command prefix`.
|
||||||
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 body: Record<string, string> = { ".proplist": "contents" }
|
||||||
}
|
if (target.id) body[".id"] = target.id
|
||||||
}
|
else if (target.name) body.name = target.name
|
||||||
throw lastError instanceof Error
|
else throw new Error("RouterOS: не задан файл для чтения")
|
||||||
? lastError
|
const raw = await rosRawRequest(this.params, {
|
||||||
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
|
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<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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -771,12 +881,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
|
||||||
@@ -796,29 +906,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; passphrase: string }> {
|
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> {
|
||||||
let passphrase = params.passphrase?.trim() || generateExportPassphrase()
|
let passphrase = params.passphrase?.trim() || generateExportPassphrase()
|
||||||
let fileName: string
|
let exported: { fileName: string; fileId: string; size: number }
|
||||||
try {
|
try {
|
||||||
fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase })
|
exported = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isPassphraseTooShortError(error)) throw error
|
if (!isPassphraseTooShortError(error)) throw error
|
||||||
// RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл.
|
// RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл.
|
||||||
const forced = generateExportPassphrase()
|
const forced = generateExportPassphrase()
|
||||||
try {
|
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
|
passphrase = forced
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
if (isPassphraseTooShortError(retryError)) throw new Error(EXPORT_PASSPHRASE_HINT)
|
if (isPassphraseTooShortError(retryError)) throw new Error(EXPORT_PASSPHRASE_HINT)
|
||||||
throw retryError
|
throw retryError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const content = await this.downloadFile(fileName)
|
const content = await this.downloadFile({
|
||||||
return { fileName, content, passphrase }
|
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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user