Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc6128086 | ||
|
|
569b6be2b4 |
@@ -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,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user