feat(ipsec): add certificate export functionality by name
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

- 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.
This commit is contained in:
Denozordec
2026-09-12 21:39:14 +07:00
parent 7365d8d8fb
commit 3eb75ea0b8
13 changed files with 520 additions and 73 deletions
@@ -0,0 +1,62 @@
import assert from "node:assert/strict"
import {
certificateRole,
isCaCertificate,
mapRosCertificateRow,
type RosCertificateRow,
} from "./certificate-parse.js"
// Реальные серты пользователя: MyCA (root) → vpn-server, client1, anakondra.
const myCa: RosCertificateRow = {
name: "MyCA",
"common-name": "MyCA",
"key-usage": "key-cert-sign,crl-sign",
ca: "",
flags: "KAT",
authority: "true",
}
const vpnServer: RosCertificateRow = {
name: "vpn-server",
"common-name": "vpn.example.com",
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
ca: "MyCA",
flags: "KLAT",
}
const client1: RosCertificateRow = {
name: "client1",
"common-name": "client1",
"key-usage": "digital-signature,key-encipherment,key-cert-sign,crl-sign,tls-server,tls-client",
ca: "MyCA",
flags: "KLAT",
}
const anakondra: RosCertificateRow = { ...client1, name: "anakondra", "common-name": "anakondra" }
{
assert.ok(isCaCertificate(myCa))
// default key-usage содержит key-cert-sign, но ca заполнен → не CA
assert.ok(!isCaCertificate(vpnServer))
assert.ok(!isCaCertificate(client1))
}
{
const ctx = {
peerCertNames: ["vpn-server"],
identityCertNames: ["client1", "anakondra"],
}
assert.equal(certificateRole(myCa, ctx), "ca")
assert.equal(certificateRole(vpnServer, ctx), "server")
// reference-based важнее key-usage: у client1 в key-usage есть tls-server, но он client
assert.equal(certificateRole(client1, ctx), "client")
assert.equal(certificateRole(anakondra, ctx), "client")
// без контекста — по key-usage (default содержит оба, поэтому server)
assert.equal(certificateRole({ name: "x", "key-usage": "tls-client" }), "client")
assert.equal(certificateRole({ name: "y", "key-usage": "digital-signature" }), "other")
}
{
const dto = mapRosCertificateRow(1, "mt", client1)
assert.equal(dto?.signedByCertName, "MyCA")
assert.equal(mapRosCertificateRow(1, "mt", myCa)?.signedByCertName, undefined)
}
console.log("certificate-parse.test.ts: ok")
+56
View File
@@ -2,6 +2,60 @@ import type { CertificateDto } from "@mmapp/contracts/certificates"
export type RosCertificateRow = Record<string, string | undefined>
/** Роль сертификата в контексте IPsec/IKEv2. */
export type CertRole = "ca" | "server" | "client" | "other"
/** Контекст для reference-based определения роли (peer/identity ссылки точнее, чем key-usage). */
export interface CertificateRoleContext {
/** Имена сертификатов из `peer.certificate`. */
peerCertNames?: Iterable<string>
/** Имена сертификатов из `identity.remote-certificate`. */
identityCertNames?: Iterable<string>
}
function rosTrue(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
/** Флаги RouterOS (`flags` = строка вида "KLAT"). */
function certFlags(row: RosCertificateRow): string {
return (row.flags ?? "").toUpperCase()
}
/**
* CA: `key-cert-sign`, серт self-signed (read-only `ca` пуст).
* Только флаг authority/`A` не годится: RouterOS ставит `A` и серверным сертам,
* т.к. дефолтный `key-usage` содержит `key-cert-sign`. Реальный признак — отсутствие подписавшего.
*/
export function isCaCertificate(row: RosCertificateRow): boolean {
const usage = row["key-usage"] ?? ""
const signer = (row.ca ?? "").trim()
if (signer !== "") return false
return certFlags(row).includes("A") || rosTrue(row.authority) || usage.includes("key-cert-sign")
}
/**
* Роль сертификата. Приоритет:
* 1. CA (authority / key-cert-sign без подписавшего);
* 2. серт из `peer.certificate` → server;
* 3. серт из `identity.remote-certificate` → client;
* 4. `key-usage` tls-server → server, tls-client → client (reference-based важнее: RouterOS
* дефолт содержит и tls-server, и tls-client).
*/
export function certificateRole(
row: RosCertificateRow,
ctx: CertificateRoleContext = {},
): CertRole {
const name = (row.name ?? "").trim()
if (isCaCertificate(row)) return "ca"
if (name && new Set(ctx.peerCertNames ?? []).has(name)) return "server"
if (name && new Set(ctx.identityCertNames ?? []).has(name)) return "client"
const usage = row["key-usage"] ?? ""
if (usage.includes("tls-server")) return "server"
if (usage.includes("tls-client")) return "client"
return "other"
}
function parseRosDate(raw: string | undefined): Date | null {
if (!raw?.trim()) return null
const d = new Date(raw)
@@ -88,6 +142,8 @@ export function mapRosCertificateRow(
trusted: row.trusted === "true",
status,
acmeStatus: row["acme-status"]?.trim() || undefined,
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca`). */
signedByCertName: (row.ca ?? "").trim() || undefined,
}
}
+14 -5
View File
@@ -107,15 +107,24 @@ export async function issueClientCertificate(
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 }> {
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 }
return exportCertificateP12ByName(client, clientCertName(userName), passphrase)
}
+46
View File
@@ -9,9 +9,14 @@ import {
ipsecManagedComment,
ipsecSlug,
ipsecUserComment,
isIke2Peer,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
userModeConfigName,
} from "./ipsec-config.js"
@@ -110,4 +115,45 @@ import {
assert.ok(text.includes("strongSwan"))
}
// ── IKEv2-only отбор ────────────────────────────────────────────────────────
{
const grePeer = { name: "gre-tunnel", "exchange-mode": "ike2", address: "1.2.3.4", passive: "no" }
const s2sPeer = { name: "s2s", "exchange-mode": "main", address: "5.6.7.8", certificate: "vpn-server" }
const rwPeer = { name: "vpn-server", "exchange-mode": "ike2", address: "0.0.0.0/0", passive: "yes", certificate: "vpn-server" }
assert.ok(isIke2Peer(rwPeer))
assert.ok(!isIke2Peer(s2sPeer))
const selected = selectIke2Peers([grePeer, s2sPeer, rwPeer])
assert.deepEqual(selected.map((p) => p.name), ["vpn-server"])
// fallback: нет строгого набора → ike2 + сертификат
const fallback = selectIke2Peers([grePeer, { name: "ike2-cert", "exchange-mode": "ike2", certificate: "c" }])
assert.deepEqual(fallback.map((p) => p.name), ["ike2-cert"])
const names = ["vpn-server"]
assert.ok(isIke2RemoteAccessIdentity(
{ peer: "vpn-server", "mode-config": "ipsec-vpn", "auth-method": "rsa-key", certificate: "vpn-server", "remote-certificate": "client1" },
names,
))
assert.ok(isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "rsa-key", certificate: "vpn-server" }, names))
// GRE/site-to-site PSK-туннели отсеиваются
assert.ok(!isIke2RemoteAccessIdentity({ peer: "s2s", "auth-method": "pre-shared-key" }, names))
assert.ok(!isIke2RemoteAccessIdentity({ peer: "vpn-server", "auth-method": "pre-shared-key" }, names))
// без ограничения по peer (набор пуст) — mode-config/серт всё равно нужен
assert.ok(isIke2RemoteAccessIdentity({ peer: "x", "mode-config": "mc" }, []))
assert.ok(!isIke2RemoteAccessIdentity({ peer: "x", "auth-method": "pre-shared-key" }, []))
}
{
const myCa = { name: "MyCA", "key-usage": "key-cert-sign,crl-sign", ca: "", flags: "KAT", authority: "true" }
const vpnServer = { name: "vpn-server", "key-usage": "digital-signature,key-encipherment,tls-server,tls-client", ca: "MyCA" }
const client1 = { name: "client1", "key-usage": "digital-signature,tls-client,tls-server", ca: "MyCA" }
const certs = [myCa, vpnServer, client1]
assert.equal(resolveIke2ServerCert(certs, [{ name: "vpn-server", certificate: "vpn-server" }])?.name, "vpn-server")
assert.equal(resolveIke2ServerCert([myCa], [])?.name, undefined)
assert.equal(resolveIke2CaName(certs, vpnServer), "MyCA")
assert.equal(resolveIke2CaName([vpnServer, client1], vpnServer), undefined)
}
console.log("ipsec-config.test.ts: ok")
+97 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"
import { PRODUCT_NAME } from "../managed-markers.js"
import { isCaCertificate } from "./certificate-parse.js"
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
@@ -24,7 +25,7 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin
return m?.[1] ?? null
}
type IdentityLike = {
export type IdentityLike = {
".id"?: string
comment?: string
"remote-id"?: string
@@ -32,7 +33,37 @@ type IdentityLike = {
peer?: string
}
type CertLike = { name?: string; "common-name"?: string }
/** Identity + поля, нужные для отбора IKEv2 remote-access. */
export interface Ike2IdentityLike extends IdentityLike {
certificate?: string
"auth-method"?: string
"mode-config"?: string
}
/** Peer (/ip/ipsec/peer). */
export interface PeerLike {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
comment?: string
}
export interface CertLike {
name?: string
"common-name"?: string
"key-usage"?: string
/** read-only: имя CA, которым подписан серт. */
ca?: string
/** флаги RouterOS (строка вида "KLAT"). */
flags?: string
authority?: string
}
/** Строка сертификата RouterOS (raw). */
type RosRow = Record<string, string | undefined>
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
@@ -76,6 +107,70 @@ export function userModeConfigName(userName: string): string {
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
}
// ── отбор IKEv2 remote-access (без GRE/site-to-site) ────────────────────────
function rosBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function isAnyAddress(a: string | undefined): boolean {
const v = (a ?? "").trim()
return v === "" || v === "0.0.0.0/0" || v === "::/0"
}
/** Peer с IKEv2 (exchange-mode ike2). */
export function isIke2Peer(p: PeerLike): boolean {
return (p["exchange-mode"] ?? "").trim().toLowerCase() === "ike2"
}
/**
* Remote-access IKEv2 peers. Строгий набор: ike2 + passive + any-address + сертификат.
* Fallback → ike2 + сертификат → любые ike2 (чтобы не потерять существующий сервер).
*/
export function selectIke2Peers<T extends PeerLike>(peers: T[]): T[] {
const ike2 = peers.filter(isIke2Peer)
const strict = ike2.filter(
(p) => rosBool(p.passive) && isAnyAddress(p.address) && Boolean((p.certificate ?? "").trim()),
)
if (strict.length > 0) return strict
const withCert = ike2.filter((p) => Boolean((p.certificate ?? "").trim()))
return withCert.length > 0 ? withCert : ike2
}
/**
* Identity участвует в IKEv2 remote-access: привязан к IKEv2 peer И имеет mode-config
* либо сертификат клиента. PSK-туннели без mode-config/сертификата отсеиваются.
* Пустой набор peer-имён → не ограничиваем по peer.
*/
export function isIke2RemoteAccessIdentity(
i: Ike2IdentityLike,
ike2PeerNames?: Iterable<string>,
): boolean {
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim())
const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key"
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
}
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
return certs.find((c) => names.has(String(c.name ?? "").trim()))
?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT)
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
}
/** CA для подписи новых клиентов: `ca` серверного серта (MyCA) → managed ipsec-ca → первый CA. */
export function resolveIke2CaName<T extends RosRow>(certs: T[], serverCert?: T | null): string | undefined {
const signed = String(serverCert?.ca ?? "").trim()
if (signed && certs.some((c) => String(c.name ?? "").trim() === signed)) return signed
if (certs.some((c) => String(c.name ?? "").trim() === IPSEC_CA_CERT)) return IPSEC_CA_CERT
const ca = certs.find((c) => isCaCertificate(c))
return ca ? String(ca.name ?? "").trim() || undefined : undefined
}
// ── пул адресов клиентов ────────────────────────────────────────────────────
function ipToInt(b: Array<number | undefined>): number {
+63 -23
View File
@@ -17,8 +17,13 @@ import {
IPSEC_SERVER_CERT,
clientCertName,
identityDisplayName,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
} from "./ipsec-config.js"
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
import {
canonicalIpsecSnapshot,
type IpsecLiveIdentity,
@@ -107,13 +112,13 @@ function asBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
const name = String(c.name ?? "")
const keyUsage = String(c["key-usage"] ?? "")
const isCa = keyUsage.includes("key-cert-sign")
const isServer = keyUsage.includes("tls-server")
const isClient = keyUsage.includes("tls-client")
const isUser = name.startsWith("ipsec-user-")
const role = certificateRole(c, {
peerCertNames: ctx.peerCertNames,
identityCertNames: ctx.identityCertNames,
})
return {
name,
commonName: c["common-name"] || undefined,
@@ -122,8 +127,9 @@ export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
expiresAt: c["invalid-after"] || undefined,
trusted: asBool(c.trusted),
hasPrivateKey: asBool(c["private-key"]),
role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other",
managed: isCa && name === IPSEC_CA_CERT
role,
signedBy: (c.ca ?? "").trim() || undefined,
managed: (name === IPSEC_CA_CERT && role === "ca")
|| name === IPSEC_SERVER_CERT
|| isUser
|| isIpsecManagedComment(c.comment),
@@ -204,9 +210,10 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/** Клиенты = все identity роутера (managed + существующие RouterOS). */
/** Клиенты = identity, участвующие в IKEv2 remote-access (managed + существующие RouterOS). */
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const ike2PeerNames = selectIke2Peers(state.peers).map((p) => (p.name ?? "").trim()).filter(Boolean)
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
const activeByRemote = new Map<string, RosIpsecActivePeer>()
for (const a of state.active) {
@@ -215,6 +222,7 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
}
return state.identities
.filter((i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames))
.map((i): IpsecClientDto => {
const comment = i.comment ?? ""
const managed = isIpsecManagedComment(comment)
@@ -257,38 +265,70 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
const server = state.server
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
/** Для отображения: managed peer, иначе первый существующий peer роутера. */
const primaryPeer = managedPeer ?? state.peers[0]
const sharedMc = state.modeConfigs.find(
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const ike2Identities = state.identities.filter(
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
)
const managedPeer = ike2Peers.find((p) => isIpsecManagedComment(p.comment))
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
/** Для отображения: managed IKEv2 peer, иначе первый существующий IKEv2 peer. */
const primaryPeer = managedPeer ?? ike2Peers[0]
// mode-config, на который ссылаются IKEv2 identity (не персональный mc-ipsec-*), fallback managed shared
const idMcName = ike2Identities
.map((i) => (i["mode-config"] ?? "").trim())
.find((n) => n && !n.startsWith("mc-ipsec-"))
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
?? state.modeConfigs.find((m) => isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-"))
const pool = sharedMc
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("key-cert-sign"))
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const caName = resolveIke2CaName(state.certs, serverCertRow)
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
const roleCtx: CertificateRoleContext = {
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
}
const referencedCertNames = new Set<string>([
...(roleCtx.peerCertNames ?? []),
...(roleCtx.identityCertNames ?? []),
...ike2Identities.map((i) => (i.certificate ?? "").trim()).filter(Boolean),
])
// только IKEv2-релевантные серты: CA сервера, referenced peer/identity и подписанные этим CA
const relevantCerts = state.certs.filter((c) => {
const n = String(c.name ?? "").trim()
if (caName && n === caName) return true
if (referencedCertNames.has(n)) return true
return Boolean(caName) && (c.ca ?? "").trim() === caName
})
const caCert = caCertRow ? mapCertificate(caCertRow, roleCtx) : undefined
const serverCert = serverCertRow ? mapCertificate(serverCertRow, roleCtx) : undefined
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
return {
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
initialized: Boolean(managedPeer && caCert && serverCert),
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined,
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
peers: state.peers.map((p) => mapPeer(server, p)),
peers: ike2Peers.map((p) => mapPeer(server, p)),
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert: caCert ? mapCertificate(caCert) : undefined,
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
caCert,
serverCert,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: state.certs.map(mapCertificate),
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
}
}