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]>
This commit is contained in:
Denozordec
2026-09-13 00:15:05 +07:00
co-authored by Cursor
parent 752256f12e
commit 569b6be2b4
3 changed files with 53 additions and 13 deletions
+4 -4
View File
@@ -112,12 +112,12 @@ export async function exportCertificateP12ByName(
client: MikrotikClient,
certName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
const name = certName.trim()
const cert = await findCertificate(client, name)
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase })
return { fileName, content, certName: name }
const { fileName, content, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase })
return { fileName, content, certName: name, passphrase: effective }
}
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
@@ -125,6 +125,6 @@ export async function exportClientP12(
client: MikrotikClient,
userName: 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)
}
+43 -3
View File
@@ -1,5 +1,6 @@
import http from "node:http"
import https from "node:https"
import { randomBytes } from "node:crypto"
import type { Server } from "../db/schema.js"
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
import type {
@@ -422,6 +423,30 @@ function unknownParameterName(error: MikrotikError): string | undefined {
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-параметр) " +
"требует у пользователя RouterOS API политику «sensitive»: " +
"/user set <api-user> policy=api,rest-api,read,write,sensitive — и уберите скрытие sensitive в группе пользователя."
export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {}
@@ -774,10 +799,25 @@ export class MikrotikClient {
}
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> {
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase })
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> {
let passphrase = params.passphrase?.trim() || generateExportPassphrase()
let fileName: string
try {
fileName = 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 })
passphrase = forced
} catch (retryError) {
if (isPassphraseTooShortError(retryError)) throw new Error(EXPORT_PASSPHRASE_HINT)
throw retryError
}
}
const content = await this.downloadFile(fileName)
return { fileName, content }
return { fileName, content, passphrase }
}
async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {