Compare commits

..
2 Commits
Author SHA1 Message Date
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
3 changed files with 54 additions and 13 deletions
+6 -6
View File
@@ -139,7 +139,7 @@ async function buildCertBundle(
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
): Promise<IpsecCertBundle> {
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,
}),
}
+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)
}
+44 -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,31 @@ 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-параметр) " +
"разрешён только пользователю, у которого в политике группы есть «sensitive». " +
"Проверьте: /user print → группа API-пользователя → /user group print; добавьте sensitive в policy группы " +
"(в группе full она уже есть)."
export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {}
@@ -774,10 +800,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> {