feat(ipsec): enhance IPsec management with peer and certificate handling
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
- Added support for managing IPsec peers, including editing and deleting functionality. - Introduced a new UI component for displaying peers and their details within the IPsec server grid. - Updated the IPsec user creation form to allow binding identities to specific peers. - Enhanced backend routes and services to handle peer patching and deletion requests. - Improved data structures to accommodate multiple peers and certificates for each server. - Added tests to ensure proper functionality of new peer management features.
This commit is contained in:
+134
-18
@@ -1,8 +1,10 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import type { FastifyReply } from "fastify"
|
||||
import {
|
||||
ipsecCertDeleteRequestSchema,
|
||||
ipsecCertExportRequestSchema,
|
||||
ipsecInitRequestSchema,
|
||||
ipsecPeerPatchSchema,
|
||||
ipsecUserCreateRequestSchema,
|
||||
ipsecUserPatchSchema,
|
||||
type IpsecCertBundle,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
buildSswanConfig,
|
||||
clientCertName,
|
||||
findFreePoolIp,
|
||||
identityDisplayName,
|
||||
ipsecManagedComment,
|
||||
ipsecUserComment,
|
||||
isIpsecManagedComment,
|
||||
@@ -37,6 +40,8 @@ import {
|
||||
putIdentity,
|
||||
patchIdentity,
|
||||
deleteIdentity,
|
||||
patchPeer,
|
||||
deletePeer,
|
||||
listByPath,
|
||||
} from "../services/ipsec-ros.js"
|
||||
import {
|
||||
@@ -240,22 +245,33 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const peer = state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const peer = (body.peerName
|
||||
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
|
||||
: undefined)
|
||||
?? state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
?? state.peers[0]
|
||||
if (!peer) {
|
||||
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
||||
}
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
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)
|
||||
if (!peer || !sharedMc || !caCert || !serverCert) {
|
||||
return reply.status(400).send({ error: "IKEv2-сервер не инициализирован — запустите мастер на вкладке «Сервер»" })
|
||||
?? state.certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
||||
if (body.authMethod === "certificate" && (!caCert || !serverCert)) {
|
||||
return reply.status(400).send({
|
||||
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
||||
})
|
||||
}
|
||||
const peerName = (peer.name ?? "").trim()
|
||||
const serverEndpoint = String(serverCert["common-name"] ?? "").trim()
|
||||
const dns = sharedMc["static-dns"]?.trim() || undefined
|
||||
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
|
||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||
|
||||
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
||||
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
|
||||
}
|
||||
|
||||
let modeConfig = (sharedMc.name ?? "").trim()
|
||||
let modeConfig = (sharedMc?.name ?? "").trim()
|
||||
if (body.staticIp) {
|
||||
const ip = body.staticIp.trim()
|
||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
@@ -271,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (body.authMethod === "certificate") {
|
||||
const issued = await issueClientCertificate(client, {
|
||||
userName: body.name,
|
||||
caCertName: IPSEC_CA_CERT,
|
||||
caCertName: String(caCert!.name ?? IPSEC_CA_CERT),
|
||||
daysValid: body.daysValid ?? 1825,
|
||||
})
|
||||
if (issued.existed) {
|
||||
@@ -285,7 +301,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
modeConfig,
|
||||
comment: ipsecUserComment(body.name),
|
||||
authMethod: body.authMethod,
|
||||
certificate: body.authMethod === "certificate" ? IPSEC_SERVER_CERT : undefined,
|
||||
certificate: body.authMethod === "certificate" ? String(serverCert?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||||
remoteCertificate: certName,
|
||||
secret: body.psk,
|
||||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||||
@@ -328,26 +344,30 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managedIdentity = isIpsecManagedComment(identity.comment)
|
||||
const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? ""
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const sharedMcName = (sharedMc?.name ?? "").trim()
|
||||
|
||||
if (d.name && d.name !== oldName) {
|
||||
// managed: user=<name>; существующий RouterOS identity: обычный comment
|
||||
await patchIdentity(client, identity[".id"]!, {
|
||||
comment: ipsecUserComment(d.name),
|
||||
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
if (d.staticIp !== undefined) {
|
||||
const currentMc = (identity["mode-config"] ?? "").trim()
|
||||
if (d.staticIp == null) {
|
||||
// вернуть выдачу из пула
|
||||
if (sharedMc) await patchIdentity(client, identity[".id"]!, { "mode-config": (sharedMc.name ?? "").trim() })
|
||||
const personal = (identity["mode-config"] ?? "").trim()
|
||||
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
|
||||
if (sharedMcName) await patchIdentity(client, identity[".id"]!, { "mode-config": sharedMcName })
|
||||
const personal = currentMc && currentMc !== sharedMcName ? currentMc : ""
|
||||
if (personal) await deleteUserModeConfig(client, personal)
|
||||
} else {
|
||||
const ip = d.staticIp.trim()
|
||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
const others = takenStaticIps(
|
||||
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== (identity["mode-config"] ?? "").trim()),
|
||||
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
|
||||
)
|
||||
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||
const name = userModeConfigName(d.name || oldName)
|
||||
@@ -381,15 +401,18 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managed = isIpsecManagedComment(identity.comment)
|
||||
const userName = parseIpsecUserComment(identity.comment) ?? ""
|
||||
const sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim()
|
||||
const personal = (identity["mode-config"] ?? "").trim()
|
||||
const remoteCert = (identity["remote-certificate"] ?? "").trim()
|
||||
|
||||
await deleteIdentity(client, identity[".id"]!)
|
||||
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
|
||||
if (removeCertificate && identity["remote-certificate"]) {
|
||||
await client.removeCertificate(identity["remote-certificate"]).catch(() => undefined)
|
||||
} else if (removeCertificate && userName) {
|
||||
await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
|
||||
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||||
if (removeCertificate) {
|
||||
// только явный сертификат клиента или managed-identity; чужие сертификаты не трогаем
|
||||
if (remoteCert) await client.removeCertificate(remoteCert).catch(() => undefined)
|
||||
else if (managed && userName) await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
|
||||
}
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
@@ -398,6 +421,99 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = ipsecPeerPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const d = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
|
||||
const body: Record<string, string> = {}
|
||||
if (d.name !== undefined) body.name = d.name
|
||||
if (d.address !== undefined) body.address = d.address
|
||||
if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode
|
||||
if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no"
|
||||
if (d.certificate !== undefined) body.certificate = d.certificate
|
||||
if (d.profile !== undefined) body.profile = d.profile
|
||||
if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no"
|
||||
if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body)
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const q = req.query as { force?: string }
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
|
||||
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
|
||||
const peerName = (peer.name ?? "").trim()
|
||||
const linked = state.identities
|
||||
.filter((i) => (i.peer ?? "").trim() === peerName)
|
||||
.map((i) => identityDisplayName(i, state.certs))
|
||||
if (linked.length > 0 && q.force !== "true") {
|
||||
return reply.status(409).send({
|
||||
error: `На peer «${peerName}» ссылаются identity: ${linked.join(", ")}. Удаление разорвёт их.`,
|
||||
identities: linked,
|
||||
})
|
||||
}
|
||||
await deletePeer(client, rosIdParam(rosId))
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/ipsec/certs/:serverId", async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string }
|
||||
const parsed = ipsecCertDeleteRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const q = req.query as { force?: string }
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const name = parsed.data.name
|
||||
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
|
||||
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
|
||||
const usedBy = [
|
||||
...state.peers.filter((p) => (p.certificate ?? "").trim() === name).map((p) => `peer ${(p.name ?? "").trim()}`),
|
||||
...state.identities
|
||||
.filter((i) => (i.certificate ?? "").trim() === name || (i["remote-certificate"] ?? "").trim() === name)
|
||||
.map((i) => `identity ${identityDisplayName(i, state.certs)}`),
|
||||
]
|
||||
if (usedBy.length > 0 && q.force !== "true") {
|
||||
return reply.status(409).send({
|
||||
error: `Сертификат «${name}» используется: ${usedBy.join(", ")}.`,
|
||||
usedBy,
|
||||
})
|
||||
}
|
||||
await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`)
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/ipsec/users/:serverId/:rosId/cert", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildSswanConfig,
|
||||
clientCertName,
|
||||
findFreePoolIp,
|
||||
identityDisplayName,
|
||||
ipsecManagedComment,
|
||||
ipsecSlug,
|
||||
ipsecUserComment,
|
||||
@@ -52,6 +53,31 @@ import {
|
||||
assert.equal(findFreePoolIp("мусор", []), null)
|
||||
}
|
||||
|
||||
{
|
||||
// managed: user= из comment
|
||||
assert.equal(identityDisplayName({ comment: ipsecUserComment("alice"), ".id": "*1" }, []), "alice")
|
||||
// существующий RouterOS identity: сырой comment
|
||||
assert.equal(identityDisplayName({ comment: "home office", ".id": "*2" }, []), "home office")
|
||||
// remote-id
|
||||
assert.equal(identityDisplayName({ "remote-id": "[email protected]", ".id": "*3" }, []), "[email protected]")
|
||||
// CN сертификата по remote-certificate
|
||||
assert.equal(
|
||||
identityDisplayName(
|
||||
{ "remote-certificate": "ipsec-user-bob", ".id": "*4" },
|
||||
[{ name: "ipsec-user-bob", "common-name": "bob" }],
|
||||
),
|
||||
"bob",
|
||||
)
|
||||
// fallback: peer#shortId
|
||||
assert.equal(
|
||||
identityDisplayName({ peer: "ikev2-srv", ".id": "*AB12CD34" }, []),
|
||||
"ikev2-srv#AB12CD",
|
||||
)
|
||||
// fallback: rosId
|
||||
assert.equal(identityDisplayName({ ".id": "*FF" }, []), "FF")
|
||||
assert.equal(identityDisplayName({}, []), "identity")
|
||||
}
|
||||
|
||||
{
|
||||
const sswan = buildSswanConfig({
|
||||
name: "IKEv2 vpn.example.com",
|
||||
|
||||
@@ -24,6 +24,35 @@ export function parseIpsecUserComment(comment: string | undefined | null): strin
|
||||
return m?.[1] ?? null
|
||||
}
|
||||
|
||||
type IdentityLike = {
|
||||
".id"?: string
|
||||
comment?: string
|
||||
"remote-id"?: string
|
||||
"remote-certificate"?: string
|
||||
peer?: string
|
||||
}
|
||||
|
||||
type CertLike = { name?: string; "common-name"?: string }
|
||||
|
||||
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
|
||||
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
|
||||
const comment = (i.comment ?? "").trim()
|
||||
const managedName = parseIpsecUserComment(comment)
|
||||
if (managedName) return managedName
|
||||
if (comment) return comment
|
||||
const remoteId = (i["remote-id"] ?? "").trim()
|
||||
if (remoteId) return remoteId
|
||||
const remoteCert = (i["remote-certificate"] ?? "").trim()
|
||||
if (remoteCert) {
|
||||
const cn = String(certs.find((c) => String(c.name ?? "").trim() === remoteCert)?.["common-name"] ?? "").trim()
|
||||
return cn || remoteCert
|
||||
}
|
||||
const rosId = String(i[".id"] ?? "").replace(/^\*/, "")
|
||||
const peer = (i.peer ?? "").trim()
|
||||
if (peer) return `${peer}#${rosId.slice(0, 6) || "identity"}`
|
||||
return rosId || "identity"
|
||||
}
|
||||
|
||||
// ── naming-конвенции управляемых объектов RouterOS ──────────────────────────
|
||||
|
||||
export const IPSEC_CA_CERT = "ipsec-ca"
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
IPSEC_COMMON_NAME,
|
||||
IPSEC_SERVER_CERT,
|
||||
clientCertName,
|
||||
identityDisplayName,
|
||||
isIpsecManagedComment,
|
||||
parseIpsecUserComment,
|
||||
} from "./ipsec-config.js"
|
||||
import {
|
||||
canonicalIpsecSnapshot,
|
||||
@@ -111,6 +111,8 @@ 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 isServer = keyUsage.includes("tls-server")
|
||||
const isClient = keyUsage.includes("tls-client")
|
||||
const isUser = name.startsWith("ipsec-user-")
|
||||
return {
|
||||
name,
|
||||
@@ -120,7 +122,7 @@ 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 ? "server" : isUser ? "client" : "other",
|
||||
role: isCa ? "ca" : name === IPSEC_SERVER_CERT || isServer ? "server" : isUser || isClient ? "client" : "other",
|
||||
managed: isCa && name === IPSEC_CA_CERT
|
||||
|| name === IPSEC_SERVER_CERT
|
||||
|| isUser
|
||||
@@ -202,14 +204,9 @@ export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerSta
|
||||
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
||||
}
|
||||
|
||||
/** Клиенты = managed-идентичности (созданы менеджером). */
|
||||
/** Клиенты = все identity роутера (managed + существующие RouterOS). */
|
||||
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) {
|
||||
@@ -218,10 +215,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
}
|
||||
|
||||
return state.identities
|
||||
.filter((i) => isIpsecManagedComment(i.comment))
|
||||
.map((i): IpsecClientDto => {
|
||||
const comment = i.comment ?? ""
|
||||
const name = parseIpsecUserComment(comment) ?? (comment || "client")
|
||||
const managed = isIpsecManagedComment(comment)
|
||||
const name = identityDisplayName(i, state.certs)
|
||||
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
|
||||
const certName = (i["remote-certificate"] ?? "").trim()
|
||||
const cn = certName
|
||||
@@ -229,8 +226,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
: ""
|
||||
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
|
||||
const staticIp = mc
|
||||
? ((mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined)
|
||||
: undefined
|
||||
const active = (cn ? activeByRemote.get(cn) : undefined)
|
||||
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
|
||||
@@ -252,7 +249,7 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
activeSince: active?.established || undefined,
|
||||
disabled: asBool(i.disabled),
|
||||
comment: comment || undefined,
|
||||
managed: true,
|
||||
managed,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
@@ -262,6 +259,8 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
|
||||
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-")),
|
||||
)
|
||||
@@ -270,7 +269,9 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
|
||||
?? 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 natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
|
||||
return {
|
||||
serverId: String(server.id),
|
||||
@@ -278,7 +279,8 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
|
||||
serverCountry: server.country ?? undefined,
|
||||
initialized: Boolean(managedPeer && caCert && serverCert),
|
||||
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
|
||||
peer: managedPeer ? mapPeer(server, managedPeer) : undefined,
|
||||
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
|
||||
peers: state.peers.map((p) => mapPeer(server, p)),
|
||||
pool: pool ? mapPool(server, pool) : undefined,
|
||||
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
||||
caCert: caCert ? mapCertificate(caCert) : undefined,
|
||||
@@ -286,15 +288,14 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
|
||||
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),
|
||||
certs: state.certs.map(mapCertificate),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
|
||||
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
|
||||
*/
|
||||
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
|
||||
const state = await fetchIpsecState(server)
|
||||
return canonicalIpsecSnapshot({
|
||||
|
||||
@@ -206,6 +206,14 @@ export async function deleteIdentity(client: MikrotikClient, rosId: string): Pro
|
||||
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
|
||||
}
|
||||
|
||||
export async function patchPeer(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
|
||||
await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body)
|
||||
}
|
||||
|
||||
export async function deletePeer(client: MikrotikClient, rosId: string): Promise<void> {
|
||||
await client.delete(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`)
|
||||
}
|
||||
|
||||
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
|
||||
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
|
||||
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
|
||||
|
||||
Reference in New Issue
Block a user