fix(ipsec): подтвердить достоверность операций и приёмку раздела

This commit is contained in:
Denozordec committed 2026-10-09 12:46:22 +07:00
1 parent e2d81f9657
commit f80d9b7637
28 files changed
+969 -170

No files matched your search

+37 -53
View File
@@ -14,7 +14,6 @@ import {
} from "@mmapp/contracts/ipsec"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
buildClientInstructions,
@@ -49,7 +48,6 @@ import {
patchPeer,
deletePeer,
ensureSharedIke2Identity,
listByPath,
} from "../services/ipsec-ros.js"
import {
ensureCaCertificate,
@@ -65,6 +63,7 @@ import {
getEnabledIpsecServerById,
listIpsec,
mapClients,
resolveIpsecClientTarget,
mapServerSummary,
} from "../services/ipsec-live.js"
import {
@@ -75,11 +74,12 @@ import {
} from "../services/config-revisions.js"
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js"
import { parseDbServerId } from "../utils/server-id.js"
import { isIP } from "node:net"
import { randomBytes } from "node:crypto"
import { z } from "zod"
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
function generatePassphrase(): string {
return randomBytes(9).toString("base64url")
@@ -174,7 +174,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledIpsecServerById(sid)
if (server) await recordIpsec(server, "observed")
if (server && !result.failures?.some(f => f.serverId === String(sid))) await recordIpsec(server, "observed")
}
return reply.send(result)
})
@@ -206,6 +206,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
await ensureSharedIke2Identity(client, { peerName: IPSEC_COMMON_NAME, serverCertName: serverCert, modeConfigName: IPSEC_COMMON_NAME, comment: ipsecManagedComment("IKEv2 listener") })
if (body.createNatRule) {
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
}
@@ -225,31 +226,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) {
const rows = await listByPath(client, path)
for (const row of rows) {
if (!row[".id"]) continue
if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) {
await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
}
}
}
const nat = await listByPath(client, "/ip/firewall/nat")
for (const row of nat) {
if (row[".id"] && isIpsecManagedComment(row.comment)) {
await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
}
}
// profile/proposal не критично оставлять; сертификаты — по флагу
if (removeCertificates) {
const certs = await client.getCertificates()
for (const c of certs) {
const name = String(c.name ?? "")
if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) {
await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined)
}
}
}
await removeManagedIpsecServer(client, removeCertificates)
await recordIpsec(server, "apply")
return reply.send({ ok: true })
} catch (e) {
@@ -275,7 +252,6 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
?? ike2Peers[0]
?? state.peers[0]
if (!peer) {
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
}
@@ -288,13 +264,15 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
.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)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
const caName = resolveIke2CaName(state.certs, serverCertRow)
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
return reply.status(400).send({
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
})
}
if (body.peerName && (peer.name ?? "").trim() !== body.peerName.trim()) return reply.status(404).send({ error: "Выбранный peer не найден" })
if (mapClients(state).some(c => c.name === body.name.trim()) || (body.authMethod === "certificate" && state.certs.some(c => c.name === clientCertName(body.name)))) return reply.status(409).send({ error: "Клиент уже существует" })
const peerName = (peer.name ?? "").trim()
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
const dns = sharedMc?.["static-dns"]?.trim() || undefined
@@ -309,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
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}` })
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const taken = takenStaticIps(state.modeConfigs)
if (taken.includes(ip)) {
return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
@@ -386,22 +364,25 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const d = parsed.data
if ("staticIp" in d && d.staticIp && isIP(d.staticIp.trim()) !== 4) return reply.status(400).send({ error: "Некорректный статический IPv4 адрес" })
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 targetRosId = rosIdParam(rosId)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
const certRow = identity
? undefined
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
const target = resolveIpsecClientTarget(state, targetRosId)
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
let { identity, certRow } = target
if (certRow) {
identity = state.identities.find(i => i["remote-certificate"] === certRow?.name)
if (identity) certRow = undefined
}
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
const managedIdentity = identity
? isIpsecManagedComment(identity.comment)
: String(certRow?.name ?? "").startsWith("ipsec-user-")
@@ -419,9 +400,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
})
}
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем)
if (certRow?.[".id"] && managedIdentity) {
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) })
// Preserve the signed certificate CN/key/name; keep the editable display name in an identity.
if (certRow && !d.staticIp) {
const peer = ike2Peers.find(p => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
if (!peer || !serverCertRow) return reply.status(400).send({ error: "Нет IKEv2 peer или серверного сертификата" })
await putIdentity(client, { peerName: peer.name ?? "", modeConfig: sharedMcName, comment: ipsecUserComment(d.name), authMethod: "certificate", certificate: serverCertRow.name, remoteCertificate: certRow.name })
}
}
@@ -434,7 +417,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
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}` })
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const others = takenStaticIps(
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
)
@@ -446,7 +429,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
} else if (!identity && certRow && d.staticIp) {
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
const ip = d.staticIp.trim()
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
const taken = takenStaticIps(state.modeConfigs)
if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
@@ -491,11 +474,10 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
try {
const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
const certRow = identity
? undefined
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
const target = resolveIpsecClientTarget(state, targetRosId)
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
const identity = target.identity ?? (target.certRow ? state.identities.find(i => i["remote-certificate"] === target.certRow?.name) : undefined)
const certRow = identity ? undefined : target.certRow
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
const managed = identity
? isIpsecManagedComment(identity.comment)
@@ -514,11 +496,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (removeCertificate) {
// удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
const ike2Peers = selectIke2Peers(state.peers)
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim()
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities))
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers, state.identities)?.name ?? "").trim()
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
if (certName && certName !== caName && certName !== serverCertName) {
await client.removeCertificate(certName).catch(() => undefined)
await client.removeCertificate(certName)
}
}
await recordIpsec(server, "apply")
@@ -547,7 +529,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
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.certificate !== undefined) return reply.status(400).send({ error: "Сертификат задаётся в IPsec identity, а не в peer" })
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)
@@ -633,7 +615,9 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
const state = await fetchIpsecState(server)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
const target = resolveIpsecClientTarget(state, rosIdParam(rosId))
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
const identity = target.identity
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
const userName = parseIpsecUserComment(identity.comment) ?? ""
if (!userName) return reply.status(400).send({ error: "Не managed-клиент" })
-3
View File
@@ -693,8 +693,6 @@ function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
address: p.address,
"exchange-mode": p.exchangeMode,
passive: rosYesNo(p.passive),
certificate: p.certificate,
"send-cert": "always",
profile: p.profile,
comment: p.comment,
disabled: rosYesNo(p.disabled),
@@ -723,7 +721,6 @@ function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string>
"address-pool": m.addressPool,
address: m.address,
"static-dns": m.staticDns,
comment: m.comment,
})
}
+2
View File
@@ -48,6 +48,8 @@ export async function ensureCaCertificate(client: MikrotikClient, daysValid: num
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
await waitCertificateSigned(client, IPSEC_CA_CERT)
}
const ca = await findCertificate(client, IPSEC_CA_CERT)
if (ca?.[".id"] && !["true", "yes"].includes(ca.trusted ?? "")) await client.patch(`/certificate/${encodeURIComponent(ca[".id"])}`, { trusted: "yes" })
return IPSEC_CA_CERT
}
+32
View File
@@ -0,0 +1,32 @@
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, isManagedIpsecModeConfig, isIpsecManagedComment } from "./ipsec-config.js"
import { listByPath } from "./ipsec-ros.js"
/** Read the whole deletion scope before writing; never treat unreadable state as empty. */
export async function removeManagedIpsecServer(client: MikrotikClient, removeCertificates: boolean): Promise<void> {
const paths = ["/ip/ipsec/identity", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/ipsec/mode-config", "/ip/pool", "/ip/firewall/nat"]
if (removeCertificates) paths.push("/certificate")
const snapshots = await Promise.all(paths.map(async path => ({ path, rows: await listByPath(client, path) })))
const owned = (path: string, row: Record<string, string | undefined>) => isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/mode-config" && isManagedIpsecModeConfig(row)) || (path === "/certificate" && (row.name === IPSEC_CA_CERT || row.name === IPSEC_SERVER_CERT || row.name?.startsWith("ipsec-user-")))
const names = (path: string) => new Set(snapshots.find(s => s.path === path)?.rows.filter(row => owned(path, row)).map(row => row.name).filter((name): name is string => typeof name === "string" && name.length > 0))
const peerNames = names("/ip/ipsec/peer"), modeConfigs = names("/ip/ipsec/mode-config"), certificates = names("/certificate")
for (const { path, rows } of snapshots) {
for (const row of rows.filter(row => !owned(path, row))) {
if (path === "/certificate" && certificates.has(row.ca ?? "")) throw new Error("Удаление заблокировано: управляемый CA подписал чужой сертификат")
if (path === "/ip/ipsec/identity" && (peerNames.has(row.peer ?? "") || modeConfigs.has(row["mode-config"] ?? "") || certificates.has(row.certificate ?? "") || certificates.has(row["remote-certificate"] ?? ""))) throw new Error("Удаление заблокировано: объекты IPsec используются чужой identity")
if (path === "/ip/ipsec/policy" && row.dynamic !== "true" && row.dynamic !== "yes" && (row.peer ?? "").split(",").some(peer => peerNames.has(peer.trim()))) throw new Error("Удаление заблокировано: peer используется чужой policy")
}
}
for (const { path, rows } of snapshots) {
if (rows.some(row => owned(path, row) && !row[".id"])) throw new Error(`Нет ID управляемого объекта: ${path}`)
}
for (const { path, rows } of snapshots) {
// Default/foreign templates are preserved. Certificates use the existing reserved naming convention.
const targets = rows.filter(row => owned(path, row))
if (path === "/certificate") targets.sort((a, b) => Number(a.name === IPSEC_CA_CERT) - Number(b.name === IPSEC_CA_CERT))
for (const row of targets) {
if (!row[".id"]) throw new Error(`Нет ID управляемого объекта: ${path}`)
await client.delete(`${path}/${encodeURIComponent(row[".id"])}`)
}
}
}
+4 -1
View File
@@ -36,6 +36,8 @@ import { identityRosBody } from "./ipsec-ros.js"
assert.ok(!isIpsecManagedComment(undefined))
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice")
assert.equal(parseIpsecUserComment(ipsecUserComment("Alice Cooper")), "Alice Cooper")
assert.equal(parseIpsecUserComment("foreign user=alice"), null)
assert.equal(parseIpsecUserComment("не managed"), null)
assert.equal(parseIpsecUserComment(null), null)
}
@@ -187,7 +189,8 @@ import { identityRosBody } from "./ipsec-ros.js"
remoteCertificate: "ipsec-user-alice",
placeBefore: "*S",
})
assert.equal(body["place-before"], "*S")
assert.equal(body["auth-method"], "digital-signature")
assert.equal(body["place-before"], undefined)
assert.equal(body["remote-certificate"], "ipsec-user-alice")
assert.equal(body["match-by"], "certificate")
}
+13 -7
View File
@@ -11,7 +11,12 @@ export function ipsecManagedComment(label = ""): string {
}
export function isIpsecManagedComment(comment: string | undefined | null): boolean {
return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER)
return Boolean(comment) && (String(comment).trim() === IPSEC_MARKER || String(comment).trim().startsWith(`${IPSEC_MARKER} `))
}
/** Reserved RouterOS names identify mode-configs on versions without a comment property. */
export function isManagedIpsecModeConfig(row: { name?: string; comment?: string }): boolean {
return isIpsecManagedComment(row.comment) || row.name === "ipsec-vpn" || Boolean(row.name?.startsWith("mc-ipsec-"))
}
/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
@@ -20,8 +25,8 @@ export function ipsecUserComment(name: string): string {
}
export function parseIpsecUserComment(comment: string | undefined | null): string | null {
if (!comment) return null
const m = /user=([^\s]+)/.exec(comment.trim())
if (!isIpsecManagedComment(comment)) return null
const m = /(?:^|\s)user=(.+)$/.exec(comment!.trim())
return m?.[1] ?? null
}
@@ -150,7 +155,7 @@ export function isIke2RemoteAccessIdentity(
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"
const rsaKey = ["rsa-key", "digital-signature"].includes((i["auth-method"] ?? "").trim().toLowerCase())
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
}
@@ -176,8 +181,9 @@ export function selectSharedIke2Identity<T extends Ike2IdentityLike>(
}
/** Серверный серт 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))
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[], identities: Ike2IdentityLike[] = []): T | undefined {
const peerNames = new Set(ike2Peers.map(p => p.name))
const names = new Set([...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...identities.filter(i => peerNames.has(i.peer)).map(i => (i.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"))
@@ -297,7 +303,7 @@ export function buildClientInstructions(args: {
`1. Скачайте и импортируйте сертификат ${args.p12Filename}.`,
pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.",
"2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»",
" (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).",
" Импортируйте .p12 в «Локальный компьютер» → «Личный», CA — в доверенные корневые сертификаты.",
"3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),",
" аутентификация — сертификат; удалённый ID = CN серверного сертификата.",
"4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).",
+52 -33
View File
@@ -1,4 +1,5 @@
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
import { parseDbServerId } from "../utils/server-id.js"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
@@ -20,6 +21,7 @@ import {
identityDisplayName,
isIke2RemoteAccessIdentity,
isIpsecManagedComment,
isManagedIpsecModeConfig,
resolveIke2CaName,
resolveIke2ServerCert,
selectIke2Peers,
@@ -196,18 +198,19 @@ export interface IpsecServerState {
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[]) : [])
export async function fetchIpsecState(server: ServerRow, reader?: MikrotikClient): Promise<IpsecServerState> {
if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
const client = reader ?? MikrotikClient.fromServer(server)
const collection = async <T>(path: string): Promise<T[]> => {
const raw = await client.get<unknown>(path)
if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"])) throw new Error(`Некорректный ответ IPsec: ${path}`)
return raw 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[]),
collection<RosIpsecPeer>("/ip/ipsec/peer"), collection<RosIpsecIdentity>("/ip/ipsec/identity"),
collection<RosIpsecModeConfig>("/ip/ipsec/mode-config"), collection<RosIpsecPool>("/ip/pool"),
collection<RosIpsecPolicy>("/ip/ipsec/policy"), collection<RosFirewallNat>("/ip/firewall/nat"),
collection<RosIpsecActivePeer>("/ip/ipsec/active-peers"), collection<RosCertRow>("/certificate"),
])
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
@@ -226,10 +229,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
const roleCtx: CertificateRoleContext = {
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
peerCertNames: [...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...state.identities.filter(i => ike2PeerNames.includes((i.peer ?? "").trim())).map(i => (i.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 caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities))
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
@@ -270,11 +273,11 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
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),
id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
serverId: String(server.id),
serverName,
name: cn || certName,
name: identity ? identityDisplayName(identity, state.certs) : (cn || certName),
authMethod: "certificate",
kind: "cert",
certificateName: certName,
@@ -305,8 +308,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const remoteId = (i["remote-id"] ?? "").trim()
const active = remoteId ? activeByRemote.get(remoteId) : undefined
out.push({
id: `${server.id}:${rosId || "identity"}`,
rosId: rosId || "identity",
id: `${server.id}:identity:${rosId}`,
rosId: `identity:${rosId}`,
serverId: String(server.id),
serverName,
name: identityDisplayName(i, state.certs),
@@ -328,6 +331,16 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
return out.sort((a, b) => a.name.localeCompare(b.name))
}
/** Explicit namespaces prevent cross-menu RouterOS ID collisions. Legacy ambiguous IDs fail closed. */
export function resolveIpsecClientTarget(state: IpsecServerState, token: string) {
const kind = token.startsWith("cert:") ? "cert" : token.startsWith("identity:") ? "identity" : undefined
const raw = kind ? token.slice(kind.length + 1) : token
const clients = mapClients(state)
const identity = kind === "cert" ? undefined : state.identities.find(i => i[".id"] === raw && clients.some(c => c.rosId === "identity:" + raw))
const certRow = kind === "identity" ? undefined : state.certs.find(c => (c[".id"] === raw || c.name === raw) && clients.some(user => user.kind === "cert" && user.certName === c.name))
return { identity, certRow, ambiguous: Boolean(!kind && identity && certRow) }
}
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
const server = state.server
const ike2Peers = selectIke2Peers(state.peers)
@@ -353,12 +366,12 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
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),
peerCertNames: [...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...state.identities.filter(i => ike2PeerNames.includes((i.peer ?? "").trim())).map(i => (i.certificate ?? "").trim())].filter(Boolean),
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
}
const referencedCertNames = new Set<string>([
@@ -432,7 +445,7 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.filter(isManagedIpsecModeConfig)
.map((m) => ({
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
@@ -496,7 +509,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
.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,
authMethod: (i["auth-method"] ?? "").trim(),
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
@@ -508,7 +521,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.filter(isManagedIpsecModeConfig)
.map((m) => ({
rosId: String(m[".id"] ?? ""),
name: (m.name ?? "").trim(),
@@ -540,17 +553,21 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
export type IpsecListResult = IpsecListResponse
export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecListResult> {
export async function listIpsec(opts?: { serverId?: string }, dependencies = {
findServer: async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
enabledServers: async () => db.select().from(servers).where(eq(servers.enabled, true)),
readState: fetchIpsecState,
}): Promise<IpsecListResult> {
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
const id = parseDbServerId(opts.serverId)
if (id === null) {
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]
const row = await dependencies.findServer(id)
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
serverRows = await dependencies.enabledServers()
}
const failures: IpsecListResult["failures"] = []
@@ -559,7 +576,8 @@ export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecList
await Promise.all(
serverRows.map(async (server) => {
try {
const state = await fetchIpsecState(server)
if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
const state = await dependencies.readState(server)
const serverClients = mapClients(state)
summaries.push(mapServerSummary(state, serverClients))
clients.push(...serverClients)
@@ -579,10 +597,11 @@ export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecList
export async function countIpsecClients():Promise<number|undefined>{return boundedCompleteCount(()=>listIpsec(),value=>completeCollectionCount(value,"clients"))}
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
export async function getEnabledIpsecServerById(serverId: string | number, lookup = async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]) {
const id = parseDbServerId(serverId)
if (id === null) return null
const row = await lookup(id)
return row?.enabled ? row : null
}
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
+21 -20
View File
@@ -1,6 +1,7 @@
import type { MikrotikClient } from "./mikrotik.js"
import {
ipsecManagedComment,
isManagedIpsecModeConfig,
selectIke2Peers,
selectSharedIke2Identity,
type Ike2IdentityLike,
@@ -21,8 +22,9 @@ export function toRosBody(obj: Record<string, string | number | boolean | undefi
type RosRow = Record<string, string | undefined>
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
const raw = await client.get<unknown>(path).catch(() => [])
return Array.isArray(raw) ? (raw as RosRow[]) : []
const raw = await client.get<unknown>(path)
if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"])) throw new Error(`Некорректный ответ IPsec: ${path}`)
return raw as RosRow[]
}
/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
@@ -49,21 +51,20 @@ async function findRosId(client: MikrotikClient, path: string, name: string): Pr
// ── инициализация IKEv2-сервера ─────────────────────────────────────────────
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> {
export async function ensureIpsecProfile(client: MikrotikClient, name: string, _comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
name,
"hash-algorithm": "sha256",
"enc-algorithm": "aes-256,aes-192,aes-128",
"dh-group": "modp2048,modp1536,modp1024",
"dh-group": "modp2048",
"nat-traversal": "yes",
comment,
}))
}
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
name,
"auth-algorithms": "sha256,sha1",
"auth-algorithms": "sha256",
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
"pfs-group": "modp2048",
comment,
@@ -80,13 +81,13 @@ export async function ensureSharedModeConfig(
name: string,
poolName: string,
dns: string | undefined,
comment: string,
_comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
"address-pool": poolName,
responder: "yes",
"static-dns": dns,
comment,
}))
}
@@ -119,7 +120,7 @@ export async function ensurePolicyTemplate(
export async function ensureIpsecPeer(
client: MikrotikClient,
name: string,
serverCertName: string,
_serverCertName: string,
profileName: string,
comment: string,
): Promise<void> {
@@ -128,8 +129,6 @@ export async function ensureIpsecPeer(
address: "0.0.0.0/0",
"exchange-mode": "ike2",
passive: "yes",
certificate: serverCertName,
"send-cert": "always",
profile: profileName,
comment,
}))
@@ -157,18 +156,21 @@ export async function putUserModeConfig(
client: MikrotikClient,
name: string,
staticIp: string,
comment: string,
_comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
address: staticIp,
comment,
responder: "yes",
"address-prefix-length": "32",
}))
}
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
const id = await findRosId(client, "/ip/ipsec/mode-config", name)
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`)
const row = (await listByPath(client, "/ip/ipsec/mode-config")).find(row => row.name === name)
if (!row?.[".id"] || !isManagedIpsecModeConfig(row)) return
if ((await listByPath(client, "/ip/ipsec/identity")).some(i => i["mode-config"] === name)) return
await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(row[".id"])}`)
}
export interface IdentityFields {
@@ -183,22 +185,21 @@ export interface IdentityFields {
/** psk. */
secret?: string
remoteId?: string
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
/** Legacy hint: RouterOS identities do not accept place-before; specificity uses match-by/remote-certificate. */
placeBefore?: string
}
export function identityRosBody(f: IdentityFields): Record<string, string> {
return toRosBody({
peer: f.peerName,
"auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key",
"auth-method": f.authMethod === "certificate" ? "digital-signature" : "pre-shared-key",
certificate: f.certificate,
"remote-certificate": f.remoteCertificate,
"match-by": f.authMethod === "certificate" ? "certificate" : undefined,
"match-by": f.authMethod === "certificate" && f.remoteCertificate ? "certificate" : undefined,
secret: f.secret,
"remote-id": f.remoteId,
"remote-id": f.remoteId && !f.remoteId.includes(":") && !["auto", "ignore"].includes(f.remoteId) ? `key-id:${f.remoteId}` : f.remoteId,
"mode-config": f.modeConfig,
"generate-policy": "port-strict",
"place-before": f.placeBefore,
comment: f.comment,
})
}
+78
View File
@@ -0,0 +1,78 @@
import assert from "node:assert/strict"
import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget } from "./ipsec-live.js"
import { removeManagedIpsecServer } from "./ipsec-cleanup.js"
import { deleteUserModeConfig, ensureIpsecPeer, identityRosBody, putOrPatchByName } from "./ipsec-ros.js"
import { ipsecManagedComment, isIpsecManagedComment } from "./ipsec-config.js"
import type { MikrotikClient } from "./mikrotik.js"
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
const paths = ["/ip/ipsec/peer", "/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/pool", "/ip/ipsec/policy", "/ip/firewall/nat", "/ip/ipsec/active-peers", "/certificate"]
let checks = 0
for (const path of paths) {
const reader = { get: async (p: string) => { if (p === path) throw Error("upstream failed"); return [] } } as unknown as MikrotikClient
await assert.rejects(fetchIpsecState(server, reader), /upstream failed/); checks++
for (const invalid of [{}, null, [null], [{}]]) {
await assert.rejects(fetchIpsecState(server, { get: async (p: string) => p === path ? invalid : [] } as unknown as MikrotikClient), /Некорректный ответ/); checks++
}
}
let reads = 0
const reader = { get: async () => { reads++; return [] } } as unknown as MikrotikClient
await assert.rejects(fetchIpsecState({ ...server, enabled: false }, reader), /выключен/)
assert.equal(reads, 0); checks++
for (const id of ["1oops", "-1", "0", "srv1"]) {
await assert.equal(await getEnabledIpsecServerById(id, async () => { throw Error("must not look up") }), null); checks++
}
assert.equal(await getEnabledIpsecServerById(1, async () => ({ ...server, enabled: false })), null); checks++
const dependencies = { findServer: async () => server, enabledServers: async () => [server, { ...server, id: 2 }], readState: async (s: typeof server) => { if (s.id === 2) throw Error("second failed"); return fetchIpsecState(s, reader) } }
const partial = await listIpsec(undefined, dependencies)
assert.equal(partial.servers.length, 1); assert.equal(partial.failures?.[0]?.serverId, "2"); checks++
reads = 0
const disabled = await listIpsec({ serverId: "1" }, { ...dependencies, findServer: async () => ({ ...server, enabled: false }) })
assert.equal(disabled.servers.length, 0); assert.match(disabled.failures?.[0]?.error ?? "", /выключен/); assert.equal(reads, 0); checks++
const state = await fetchIpsecState(server, { get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? [{ ".id": "*I", peer: "ike", "auth-method": "digital-signature", certificate: "server", "mode-config": "mc" }] : p === "/certificate" ? [{ ".id": "*CS", name: "server", ca: "ca", "key-usage": "tls-server,tls-client" }, { ".id": "*CA", name: "ca", "key-usage": "key-cert-sign" }] : [] } as unknown as MikrotikClient)
assert.equal(mapClients(state).length, 0); assert.equal(mapServerSummary(state, []).serverCert?.role, "server"); checks++
assert.equal(identityRosBody({ peerName: "ike", modeConfig: "mc", comment: "test", authMethod: "certificate", certificate: "server" })["auth-method"], "digital-signature")
assert.equal(identityRosBody({ peerName: "ike", modeConfig: "mc", comment: "test", authMethod: "pre-shared-key", remoteId: "alice" })["remote-id"], "key-id:alice"); checks++
assert.equal(isIpsecManagedComment("MikrotikManager:ipsec-foreign"), false); checks++
const writes: Array<{ path: string; body?: Record<string, string> }> = []
await ensureIpsecPeer({ get: async () => [], put: async (path: string, body: Record<string, string>) => writes.push({ path, body }) } as unknown as MikrotikClient, "ike", "server", "profile", "test")
assert.equal(writes[0]?.body?.certificate, undefined); assert.equal(writes[0]?.body?.["send-cert"], undefined); checks++
await assert.rejects(putOrPatchByName({ get: async () => { throw Error("read failed") }, put: async () => { throw Error("must not write") } } as unknown as MikrotikClient, "/ip/pool", "x", {}), /read failed/); checks++
for (const failRead of [false, true]) {
const deletes: string[] = []
const client = { get: async (p: string) => {
if (failRead && p === "/certificate") throw Error("cert unreadable")
return [{ ".id": "*FOREIGN", template: "true", comment: "foreign" }, { ".id": "*OWN", comment: ipsecManagedComment("test") }]
}, delete: async (p: string) => { deletes.push(p) } } as unknown as MikrotikClient
if (failRead) { await assert.rejects(removeManagedIpsecServer(client, true), /cert unreadable/); assert.deepEqual(deletes, []) }
else { await removeManagedIpsecServer(client, true); assert.equal(deletes.length, 7); assert(deletes.every(p => p.endsWith("*OWN"))) }
checks++
}
let deletes = 0
await assert.rejects(removeManagedIpsecServer({ get: async () => [{ ".id": "*OWN", comment: ipsecManagedComment("test") }], delete: async () => { deletes++; throw Error("delete denied") } } as unknown as MikrotikClient, false), /delete denied/)
assert.equal(deletes, 1); checks++
for (const shared of [false, true]) {
const removed: string[] = []
await deleteUserModeConfig({ get: async (p: string) => p.endsWith("mode-config") ? [{ ".id": "*M", name: "mc", comment: shared ? ipsecManagedComment("test") : "foreign" }] : [{ ".id": "*I", "mode-config": "mc" }], delete: async (p: string) => { removed.push(p) } } as unknown as MikrotikClient, "mc")
assert.deepEqual(removed, []); checks++
}
for (const reference of ["identity", "policy"]) {
let writes = 0
await assert.rejects(removeManagedIpsecServer({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "own", comment: ipsecManagedComment("test") }] : path.endsWith(reference) ? [{ ".id": "*F", peer: "own", comment: "foreign" }] : [], delete: async () => { writes++ } } as unknown as MikrotikClient, false), /чужой/)
assert.equal(writes, 0); checks++
}
state.certs.push({ ".id": "*CLIENT", name: "client", "common-name": "Original CN", ca: "ca", "key-usage": "tls-client" })
state.identities.push({ ".id": "*PERSONAL", peer: "ike", "auth-method": "digital-signature", "remote-certificate": "client", certificate: "server", comment: ipsecManagedComment("user=Alice Cooper") })
assert.equal(mapClients(state)[0]?.name, "Alice Cooper"); assert.equal(mapClients(state)[0]?.commonName, "Original CN"); checks++
state.certs.push({ ".id": "*COLLISION", name: "another-client", "common-name": "Certificate", ca: "ca", "key-usage": "tls-client" })
state.identities.push({ ".id": "*COLLISION", peer: "ike", "auth-method": "pre-shared-key", comment: ipsecManagedComment("user=PSK"), "mode-config": "mc" })
assert.equal(resolveIpsecClientTarget(state, "*COLLISION").ambiguous, true); checks++
assert.equal(resolveIpsecClientTarget(state, "identity:*COLLISION").identity?.["auth-method"], "pre-shared-key"); checks++
assert.equal(resolveIpsecClientTarget(state, "cert:*COLLISION").certRow?.name, "another-client"); checks++
assert.equal(resolveIpsecClientTarget(state, "cert:*CA").certRow, undefined); checks++
state.identities.find(i => i[".id"] === "*PERSONAL")!.comment = "Existing identity label"
assert.equal(mapClients(state).find(c => c.certName === "client")?.name, "Existing identity label"); checks++
let foreignCertificateWrites = 0
await assert.rejects(removeManagedIpsecServer({ get: async (path: string) => path === "/certificate" ? [{ ".id": "*CA", name: "ipsec-ca" }, { ".id": "*FOREIGN", name: "external-client", ca: "ipsec-ca" }] : [], delete: async () => { foreignCertificateWrites++ } } as unknown as MikrotikClient, true), /чужой сертификат/)
assert.equal(foreignCertificateWrites, 0); checks++
console.log(`ipsec-state: ${checks} checks passed`)