fix(ipsec): подтвердить достоверность операций и приёмку раздела
This commit is contained in:
1 parent
e2d81f9657
commit
f80d9b7637
28 files changed
+969
-170
No files matched your search
+37
-53
@@ -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-клиент" })
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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-профиля (сертификат уже внутри).",
|
||||
|
||||
@@ -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 сертификата). */
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
Reference in new issue
Block a user