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 committed 2026-09-12 20:20:56 +07:00
1 parent 564aae21f0
commit 7755d77340
35 files changed
+4154 -13

No files matched your search

+513
View File
@@ -0,0 +1,513 @@
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,
isIpsecManagedComment,
parseIpsecUserComment,
} from "./ipsec-config.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): IpsecCertInfoDto {
const name = String(c.name ?? "")
const keyUsage = String(c["key-usage"] ?? "")
const isCa = keyUsage.includes("key-cert-sign")
const isUser = name.startsWith("ipsec-user-")
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: isCa ? "ca" : name === IPSEC_SERVER_CERT ? "server" : isUser ? "client" : "other",
managed: isCa && name === IPSEC_CA_CERT
|| 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 }
}
/** Клиенты = managed-идентичности (созданы менеджером). */
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const certByCommonName = new Map<string, RosCertRow>()
for (const c of state.certs) {
const cn = String(c["common-name"] ?? "").trim()
if (cn) certByCommonName.set(cn, c)
}
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
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)
}
return state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i): IpsecClientDto => {
const comment = i.comment ?? ""
const name = parseIpsecUserComment(comment) ?? (comment || "client")
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
const certName = (i["remote-certificate"] ?? "").trim()
const cn = certName
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "")
: ""
const mcName = (i["mode-config"] ?? "").trim()
const mc = mcByName.get(mcName)
const staticIp = mc && (mc.name ?? "").startsWith("mc-ipsec-")
? (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "") || undefined
: undefined
const active = (cn ? activeByRemote.get(cn) : undefined)
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
return {
id: `${server.id}:${String(i[".id"] ?? "identity")}`,
rosId: String(i[".id"] ?? "identity"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name,
authMethod: psk ? "pre-shared-key" : "certificate",
certificateName: certName || undefined,
commonName: cn || undefined,
remoteId: (i["remote-id"] ?? "").trim() || undefined,
staticIp,
modeConfigName: mcName || undefined,
peerName: (i.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(i.disabled),
comment: comment || undefined,
managed: true,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
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)
const sharedMc = state.modeConfigs.find(
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (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)
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
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,
peer: managedPeer ? mapPeer(server, managedPeer) : undefined,
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert: caCert ? mapCertificate(caCert) : undefined,
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: state.certs
.filter((c) => {
const n = String(c.name ?? "")
return n === IPSEC_CA_CERT || n === IPSEC_SERVER_CERT || n.startsWith("ipsec-user-")
})
.map(mapCertificate),
}
}
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 }