import type { MikrotikClient } from "./mikrotik.js" import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js" type RosCertRow = Record 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 { 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 { 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 { 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 { 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 { 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 { 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; passphrase: string }> { const name = certName.trim() const cert = await findCertificate(client, name) if (!cert) throw new Error(`Сертификат ${name} не найден на роутере`) const { fileName, content, passphrase: effective } = await client.exportCertificatePkcs12({ name, passphrase }) return { fileName, content, certName: name, passphrase: effective } } /** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */ export async function exportClientP12( client: MikrotikClient, userName: string, passphrase: string, ): Promise<{ fileName: string; content: Buffer; certName: string; passphrase: string }> { return exportCertificateP12ByName(client, clientCertName(userName), passphrase) }