Files
MikrotikManager/backend/src/services/ipsec-ca.ts
T
Denozordec 3eb75ea0b8
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m41s
Docker images / frontend-image (push) Successful in 2m57s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m48s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
feat(ipsec): add certificate export functionality by name
- Implemented a new backend route for exporting existing client certificates in .p12 format by name.
- Enhanced the frontend to support exporting certificates directly from the IPsec server grid.
- Updated the IPsec page to manage certificate states and handle exports effectively.
- Introduced new utility functions for certificate handling and improved data structures to accommodate the changes.
- Added tests to ensure the reliability of the new certificate export feature.
2026-09-12 21:39:14 +07:00

131 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js"
type RosCertRow = Record<string, string | undefined>
const SIGN_POLL_TIMEOUT_MS = 90_000
const SIGN_POLL_INTERVAL_MS = 1_000
function isIp(value: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value.trim())
}
export async function findCertificate(client: MikrotikClient, name: string): Promise<RosCertRow | undefined> {
const certs = await client.getCertificates()
return certs.find((c) => String(c.name ?? "").trim() === name)
}
/** Сертификат подписан: у него заполнен invalid-after. */
export async function isCertificateSigned(client: MikrotikClient, name: string): Promise<boolean> {
const cert = await findCertificate(client, name)
return Boolean(cert && String(cert["invalid-after"] ?? "").trim() !== "")
}
/** sign в RouterOS не мгновенный: ждём появления invalid-after. */
export async function waitCertificateSigned(client: MikrotikClient, name: string, timeoutMs = SIGN_POLL_TIMEOUT_MS): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isCertificateSigned(client, name)) return
await new Promise((resolve) => setTimeout(resolve, SIGN_POLL_INTERVAL_MS))
}
throw new Error(`Сертификат ${name} не подписан за ${Math.round(timeoutMs / 1000)} с`)
}
/** Локальный CA для IKEv2 (self-signed); идемпотентно. Возвращает имя сертификата. */
export async function ensureCaCertificate(client: MikrotikClient, daysValid: number): Promise<string> {
const existing = await findCertificate(client, IPSEC_CA_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_CA_CERT,
"common-name": "MikrotikManager IPsec CA",
"key-size": "4096",
"key-usage": "key-cert-sign,crl-sign",
"days-valid": String(daysValid),
comment: ipsecManagedComment("CA"),
})
}
if (!(await isCertificateSigned(client, IPSEC_CA_CERT))) {
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
await waitCertificateSigned(client, IPSEC_CA_CERT)
}
return IPSEC_CA_CERT
}
/** Серверный сертификат (CN/SAN = адрес, по которому стучатся клиенты); подписывается CA. */
export async function ensureServerCertificate(
client: MikrotikClient,
args: { serverEndpoint: string; caCertName: string; daysValid: number },
): Promise<string> {
const endpoint = args.serverEndpoint.trim()
const san = isIp(endpoint) ? `IP:${endpoint}` : `DNS:${endpoint}`
const existing = await findCertificate(client, IPSEC_SERVER_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_SERVER_CERT,
"common-name": endpoint,
"subject-alt-name": san,
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-server",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment("server"),
})
}
if (!(await isCertificateSigned(client, IPSEC_SERVER_CERT))) {
await client.signCertificate({ name: IPSEC_SERVER_CERT, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, IPSEC_SERVER_CERT)
}
return IPSEC_SERVER_CERT
}
export interface IssuedClientCert {
certName: string
commonName: string
/** Сертификат с этим CN уже существовал (перевыпуск не выполнялся). */
existed: boolean
}
/** Клиентский сертификат: add + sign CA. CN = имя пользователя. */
export async function issueClientCertificate(
client: MikrotikClient,
args: { userName: string; caCertName: string; daysValid: number },
): Promise<IssuedClientCert> {
const certName = clientCertName(args.userName)
const existing = await findCertificate(client, certName)
if (existing) {
return { certName, commonName: String(existing["common-name"] ?? args.userName), existed: true }
}
await client.addCertificate({
name: certName,
"common-name": args.userName.trim(),
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-client",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment(`client ${args.userName.trim()}`),
})
await client.signCertificate({ name: certName, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, certName)
return { certName, commonName: args.userName.trim(), existed: false }
}
/** Экспорт произвольного сертификата по имени (существующие client1/anakondra и т.п.). */
export async function exportCertificateP12ByName(
client: MikrotikClient,
certName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: 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 }
}
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
export async function exportClientP12(
client: MikrotikClient,
userName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
}