diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index efeb0a9..2877a33 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -139,7 +139,7 @@ async function buildCertBundle( args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string }, ): Promise { 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") return { user: args.userName, @@ -147,7 +147,7 @@ async function buildCertBundle( filename: fileName, contentB64: p12B64, mime: "application/x-pkcs12", - passphrase: args.passphrase, + passphrase, sswanFilename: `${certName}.sswan`, sswanContent: buildSswanConfig({ name: `IKEv2 ${args.serverEndpoint}`, @@ -159,7 +159,7 @@ async function buildCertBundle( userName: args.userName, serverEndpoint: args.serverEndpoint, p12Filename: fileName, - passphrase: args.passphrase, + passphrase, 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 dns = sharedMc?.["static-dns"]?.trim() || undefined // export работает по имени сертификата (certName), не по userName - const { fileName, content } = await client.exportCertificatePkcs12({ + const { fileName, content, passphrase } = await client.exportCertificatePkcs12({ name: certName, passphrase: body.passphrase, }) @@ -657,7 +657,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { filename: fileName, contentB64: p12B64, mime: "application/x-pkcs12", - passphrase: body.passphrase, + passphrase, sswanFilename: `${certName}.sswan`, sswanContent: buildSswanConfig({ name: `IKEv2 ${serverEndpoint}`, @@ -669,7 +669,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { userName, serverEndpoint, p12Filename: fileName, - passphrase: body.passphrase, + passphrase, dns, }), } diff --git a/backend/src/services/ipsec-ca.ts b/backend/src/services/ipsec-ca.ts index edb87f8..0e627e2 100644 --- a/backend/src/services/ipsec-ca.ts +++ b/backend/src/services/ipsec-ca.ts @@ -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) } diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 357bfb3..b05b5b1 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -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 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 {