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
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user