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
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:
@@ -139,7 +139,7 @@ async function buildCertBundle(
|
|||||||
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
|
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
|
||||||
): Promise<IpsecCertBundle> {
|
): Promise<IpsecCertBundle> {
|
||||||
const certName = args.certName?.trim() || clientCertName(args.userName)
|
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")
|
const p12B64 = content.toString("base64")
|
||||||
return {
|
return {
|
||||||
user: args.userName,
|
user: args.userName,
|
||||||
@@ -147,7 +147,7 @@ async function buildCertBundle(
|
|||||||
filename: fileName,
|
filename: fileName,
|
||||||
contentB64: p12B64,
|
contentB64: p12B64,
|
||||||
mime: "application/x-pkcs12",
|
mime: "application/x-pkcs12",
|
||||||
passphrase: args.passphrase,
|
passphrase,
|
||||||
sswanFilename: `${certName}.sswan`,
|
sswanFilename: `${certName}.sswan`,
|
||||||
sswanContent: buildSswanConfig({
|
sswanContent: buildSswanConfig({
|
||||||
name: `IKEv2 ${args.serverEndpoint}`,
|
name: `IKEv2 ${args.serverEndpoint}`,
|
||||||
@@ -159,7 +159,7 @@ async function buildCertBundle(
|
|||||||
userName: args.userName,
|
userName: args.userName,
|
||||||
serverEndpoint: args.serverEndpoint,
|
serverEndpoint: args.serverEndpoint,
|
||||||
p12Filename: fileName,
|
p12Filename: fileName,
|
||||||
passphrase: args.passphrase,
|
passphrase,
|
||||||
dns: args.dns,
|
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 sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||||
// export работает по имени сертификата (certName), не по userName
|
// export работает по имени сертификата (certName), не по userName
|
||||||
const { fileName, content } = await client.exportCertificatePkcs12({
|
const { fileName, content, passphrase } = await client.exportCertificatePkcs12({
|
||||||
name: certName,
|
name: certName,
|
||||||
passphrase: body.passphrase,
|
passphrase: body.passphrase,
|
||||||
})
|
})
|
||||||
@@ -657,7 +657,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
filename: fileName,
|
filename: fileName,
|
||||||
contentB64: p12B64,
|
contentB64: p12B64,
|
||||||
mime: "application/x-pkcs12",
|
mime: "application/x-pkcs12",
|
||||||
passphrase: body.passphrase,
|
passphrase,
|
||||||
sswanFilename: `${certName}.sswan`,
|
sswanFilename: `${certName}.sswan`,
|
||||||
sswanContent: buildSswanConfig({
|
sswanContent: buildSswanConfig({
|
||||||
name: `IKEv2 ${serverEndpoint}`,
|
name: `IKEv2 ${serverEndpoint}`,
|
||||||
@@ -669,7 +669,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
userName,
|
userName,
|
||||||
serverEndpoint,
|
serverEndpoint,
|
||||||
p12Filename: fileName,
|
p12Filename: fileName,
|
||||||
passphrase: body.passphrase,
|
passphrase,
|
||||||
dns,
|
dns,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,12 +112,12 @@ export async function exportCertificateP12ByName(
|
|||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
certName: string,
|
certName: string,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
): Promise<{ fileName: string; content: Buffer; certName: string }> {
|
): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> {
|
||||||
const name = certName.trim()
|
const name = certName.trim()
|
||||||
const cert = await findCertificate(client, name)
|
const cert = await findCertificate(client, name)
|
||||||
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
|
if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`)
|
||||||
const { fileName, content } = await client.exportCertificatePkcs12({ name, passphrase })
|
const { fileName, content, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase })
|
||||||
return { fileName, content, certName: name }
|
return { fileName, content, certName: name, passphrase: effective }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
|
||||||
@@ -125,6 +125,6 @@ export async function exportClientP12(
|
|||||||
client: MikrotikClient,
|
client: MikrotikClient,
|
||||||
userName: string,
|
userName: string,
|
||||||
passphrase: 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)
|
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
|
import { randomBytes } from "node:crypto"
|
||||||
import type { Server } from "../db/schema.js"
|
import type { Server } from "../db/schema.js"
|
||||||
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||||
import type {
|
import type {
|
||||||
@@ -422,6 +423,30 @@ function unknownParameterName(error: MikrotikError): string | undefined {
|
|||||||
return match?.[1]
|
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 {
|
export class MikrotikClient {
|
||||||
constructor(private readonly params: MikrotikConnectParams) {}
|
constructor(private readonly params: MikrotikConnectParams) {}
|
||||||
|
|
||||||
@@ -774,10 +799,25 @@ export class MikrotikClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
|
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
|
||||||
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> {
|
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer; passphrase: string }> {
|
||||||
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase })
|
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)
|
const content = await this.downloadFile(fileName)
|
||||||
return { fileName, content }
|
return { 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