feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s

- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
Denozordec
2026-09-12 20:20:56 +07:00
parent 564aae21f0
commit 7755d77340
35 changed files with 4154 additions and 13 deletions
+121
View File
@@ -0,0 +1,121 @@
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 }
}
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
export async function exportClientP12(
client: MikrotikClient,
userName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
const certName = clientCertName(userName)
const cert = await findCertificate(client, certName)
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
return { fileName, content, certName }
}