Files
MikrotikManager/backend/src/services/ipsec-live.ts
T
DenozordecandCursor 0e1524c600
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Co-authored-by: Cursor <[email protected]>
2026-09-12 22:06:35 +07:00

619 lines
23 KiB
TypeScript

import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type {
IpsecCertInfoDto,
IpsecClientDto,
IpsecListResponse,
IpsecModeConfigDto,
IpsecPeerDto,
IpsecPoolDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
clientCertName,
identityDisplayName,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
selectSharedIke2Identity,
} from "./ipsec-config.js"
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
import {
canonicalIpsecSnapshot,
type IpsecLiveIdentity,
type IpsecLiveModeConfig,
type IpsecLiveNat,
type IpsecLivePeer,
type IpsecLivePool,
type IpsecSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect
export interface RosIpsecPeer {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
profile?: string
disabled?: string
comment?: string
}
export interface RosIpsecIdentity {
".id"?: string
peer?: string
"auth-method"?: string
certificate?: string
"remote-certificate"?: string
"match-by"?: string
secret?: string
"remote-id"?: string
"mode-config"?: string
"generate-policy"?: string
disabled?: string
comment?: string
}
export interface RosIpsecModeConfig {
".id"?: string
name?: string
"address-pool"?: string
"address-prefix"?: string
address?: string
"split-dns"?: string
"static-dns"?: string
comment?: string
}
export interface RosIpsecPool {
".id"?: string
name?: string
ranges?: string
comment?: string
}
export interface RosIpsecPolicy {
".id"?: string
"src-address"?: string
"dst-address"?: string
proposal?: string
template?: string
comment?: string
}
export interface RosFirewallNat {
".id"?: string
chain?: string
action?: string
"src-address"?: string
comment?: string
}
export interface RosIpsecActivePeer {
".id"?: string
address?: string
"remote-id"?: string
identity?: string
established?: string
}
type RosCertRow = Record<string, string | undefined>
function asBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
const name = String(c.name ?? "")
const isUser = name.startsWith("ipsec-user-")
const role = certificateRole(c, {
peerCertNames: ctx.peerCertNames,
identityCertNames: ctx.identityCertNames,
})
return {
name,
commonName: c["common-name"] || undefined,
keySize: c["key-size"] || undefined,
fingerprint: c.fingerprint || undefined,
expiresAt: c["invalid-after"] || undefined,
trusted: asBool(c.trusted),
hasPrivateKey: asBool(c["private-key"]),
role,
signedBy: (c.ca ?? "").trim() || undefined,
managed: (name === IPSEC_CA_CERT && role === "ca")
|| name === IPSEC_SERVER_CERT
|| isUser
|| isIpsecManagedComment(c.comment),
}
}
function mapPeer(server: ServerRow, p: RosIpsecPeer): IpsecPeerDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "peer")}`,
rosId: String(p[".id"] ?? p.name ?? "peer"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name: (p.name ?? "").trim(),
address: p.address || undefined,
exchangeMode: p["exchange-mode"] || undefined,
passive: asBool(p.passive),
certificate: p.certificate || undefined,
profile: p.profile || undefined,
disabled: asBool(p.disabled),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfigDto {
return {
id: `${server.id}:${String(m[".id"] ?? m.name ?? "mc")}`,
rosId: String(m[".id"] ?? m.name ?? "mc"),
serverId: String(server.id),
name: (m.name ?? "").trim(),
addressPool: m["address-pool"] || m["address-prefix"] || undefined,
address: m.address || undefined,
splitDns: m["split-dns"] || undefined,
staticDns: m["static-dns"] || undefined,
comment: m.comment || undefined,
managed: isIpsecManagedComment(m.comment),
}
}
function mapPool(server: ServerRow, p: RosIpsecPool): IpsecPoolDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "pool")}`,
rosId: String(p[".id"] ?? p.name ?? "pool"),
serverId: String(server.id),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
export interface IpsecServerState {
server: ServerRow
client: MikrotikClient
peers: RosIpsecPeer[]
identities: RosIpsecIdentity[]
modeConfigs: RosIpsecModeConfig[]
pools: RosIpsecPool[]
policies: RosIpsecPolicy[]
nat: RosFirewallNat[]
active: RosIpsecActivePeer[]
certs: RosCertRow[]
}
export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerState> {
const client = MikrotikClient.fromServer(server)
const empty = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
client.get<unknown>("/ip/ipsec/peer").then((v) => empty<RosIpsecPeer>(v)).catch(() => [] as RosIpsecPeer[]),
client.get<unknown>("/ip/ipsec/identity").then((v) => empty<RosIpsecIdentity>(v)).catch(() => [] as RosIpsecIdentity[]),
client.get<unknown>("/ip/ipsec/mode-config").then((v) => empty<RosIpsecModeConfig>(v)).catch(() => [] as RosIpsecModeConfig[]),
client.get<unknown>("/ip/pool").then((v) => empty<RosIpsecPool>(v)).catch(() => [] as RosIpsecPool[]),
client.get<unknown>("/ip/ipsec/policy").then((v) => empty<RosIpsecPolicy>(v)).catch(() => [] as RosIpsecPolicy[]),
client.get<unknown>("/ip/firewall/nat").then((v) => empty<RosFirewallNat>(v)).catch(() => [] as RosFirewallNat[]),
client.get<unknown>("/ip/ipsec/active-peers").then((v) => empty<RosIpsecActivePeer>(v)).catch(() => [] as RosIpsecActivePeer[]),
client.getCertificates().catch(() => [] as RosCertRow[]),
])
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/**
* Клиенты IKEv2:
* 1) клиентские сертификаты (роль client) — включая существующие client1/anakondra на устройстве;
* 2) PSK-identity.
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
*/
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
const roleCtx: CertificateRoleContext = {
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
}
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
const activeByRemote = new Map<string, RosIpsecActivePeer>()
for (const a of state.active) {
const rid = String(a["remote-id"] ?? "").trim()
if (rid) activeByRemote.set(rid, a)
}
const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
for (const i of state.identities) {
const rc = (i["remote-certificate"] ?? "").trim()
if (rc) identityByRemoteCert.set(rc, i)
}
const personalStaticIp = (mcNameRaw: string): string | undefined => {
const mc = mcByName.get(mcNameRaw)
if (!mc) return undefined
return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined
}
const out: IpsecClientDto[] = []
// 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
for (const cert of state.certs) {
const certName = String(cert.name ?? "").trim()
if (!certName) continue
if (certificateRole(cert, roleCtx) !== "client") continue
const signedBy = (cert.ca ?? "").trim()
const isUser = certName.startsWith("ipsec-user-")
const referenced = identityByRemoteCert.has(certName)
if (!isUser && !referenced && !(caName && signedBy === caName)) continue
const identity = identityByRemoteCert.get(certName)
const comment = identity?.comment ?? ""
const mcName = (identity?.["mode-config"] ?? "").trim()
const cn = String(cert["common-name"] ?? "").trim()
const active = (cn ? activeByRemote.get(cn) : undefined)
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
out.push({
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
serverId: String(server.id),
serverName,
name: cn || certName,
authMethod: "certificate",
kind: "cert",
certificateName: certName,
certName,
signedBy: signedBy || undefined,
commonName: cn || undefined,
remoteId: (identity?.["remote-id"] ?? "").trim() || undefined,
staticIp: personalStaticIp(mcName),
modeConfigName: mcName || undefined,
peerName: (identity?.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(identity?.disabled),
comment: comment || undefined,
managed: isUser || isIpsecManagedComment(comment),
})
}
// 2. PSK-identity (managed или IKEv2 remote-access).
for (const i of state.identities) {
if ((i["auth-method"] ?? "").trim().toLowerCase() !== "pre-shared-key") continue
const rosId = String(i[".id"] ?? "")
if (sharedRosId && rosId === sharedRosId) continue
const managed = isIpsecManagedComment(i.comment)
if (!managed && !isIke2RemoteAccessIdentity(i, ike2PeerNames)) continue
const mcName = (i["mode-config"] ?? "").trim()
const remoteId = (i["remote-id"] ?? "").trim()
const active = remoteId ? activeByRemote.get(remoteId) : undefined
out.push({
id: `${server.id}:${rosId || "identity"}`,
rosId: rosId || "identity",
serverId: String(server.id),
serverName,
name: identityDisplayName(i, state.certs),
authMethod: "pre-shared-key",
kind: "psk",
remoteId: remoteId || undefined,
staticIp: personalStaticIp(mcName),
modeConfigName: mcName || undefined,
peerName: (i.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(i.disabled),
comment: i.comment || undefined,
managed,
})
}
return out.sort((a, b) => a.name.localeCompare(b.name))
}
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
const server = state.server
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 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),
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined,
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
peers: ike2Peers.map((p) => mapPeer(server, p)),
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert,
serverCert,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
}
}
/**
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
*/
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
const state = await fetchIpsecState(server)
return canonicalIpsecSnapshot({
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "").trim(),
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
policies: state.policies
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
srcAddress: (p["src-address"] ?? "").trim(),
dstAddress: (p["dst-address"] ?? "").trim(),
proposal: (p.proposal ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
})
}
export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
client: MikrotikClient
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
snapshot: IpsecSnapshot
}> {
const state = await fetchIpsecState(server)
return {
client: state.client,
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
rosId: String(i[".id"] ?? ""),
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "") === "pre-shared-key" ? "pre-shared-key" as const : "rsa-key" as const,
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
rosId: String(m[".id"] ?? ""),
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
rosId: String(n[".id"] ?? ""),
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
snapshot: await captureIpsecSnapshot(server),
}
}
export type IpsecListResult = IpsecListResponse
export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecListResult> {
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: IpsecListResult["failures"] = []
const summaries: IpsecServerSummaryDto[] = []
const clients: IpsecClientDto[] = []
await Promise.all(
serverRows.map(async (server) => {
try {
const state = await fetchIpsecState(server)
const serverClients = mapClients(state)
summaries.push(mapServerSummary(state, serverClients))
clients.push(...serverClients)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
}
}),
)
summaries.sort((a, b) => a.serverName.localeCompare(b.serverName))
clients.sort((a, b) => a.name.localeCompare(b.name))
return { servers: summaries, clients, failures }
}
export async function countIpsecClients(): Promise<number> {
try {
const result = await Promise.race([
listIpsec(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.clients.length
} catch {
return 0
}
}
export async function getEnabledIpsecServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
export async function listIpsecClientsForCatalog(serverId: number): Promise<{
clients: IpsecClientDto[]
error?: string
}> {
const row = await getEnabledIpsecServerById(serverId)
if (!row) return { clients: [], error: "Сервер не найден" }
try {
const state = await Promise.race([
fetchIpsecState(row),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут RouterOS")), 5_000)
}),
])
return { clients: mapClients(state) }
} catch (e) {
return { clients: [], error: e instanceof Error ? e.message : String(e) }
}
}
export { clientCertName }