fix(ipsec): различать частичный и неподтверждённый результат операций
This commit is contained in:
1 parent
a83e4734df
commit
43b00e0740
15 files changed
+263
-37
No files matched your search
@@ -2,12 +2,12 @@ import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||
import { createIpsecRoutes } from "./ipsec.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import { fetchIpsecState } from "../services/ipsec-live.js"
|
||||
const original = MikrotikClient.fromServer
|
||||
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
|
||||
let patches = 0, recordings = 0, nativeDisabled = "false", rejectWrite = false
|
||||
MikrotikClient.fromServer = () => ({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2", disabled: nativeDisabled }] : [], patch: async (_path: string, body: Record<string, string>) => { if (rejectWrite) throw Error("write rejected"); patches++; nativeDisabled = body.disabled! } }) as unknown as MikrotikClient
|
||||
MikrotikClient.fromServer = () => ({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2", disabled: nativeDisabled }] : [], put: async () => {}, post: async () => {}, delete: async () => {}, patch: async (_path: string, body: Record<string, string>) => { if (rejectWrite) throw new MikrotikError(403, "/ip/ipsec/peer", "write rejected"); patches++; nativeDisabled = body.disabled! } }) as unknown as MikrotikClient
|
||||
try {
|
||||
for (const historySaved of [false, true]) {
|
||||
patches = recordings = 0; nativeDisabled = "false"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
|
||||
import { createIpsecRoutes } from "./ipsec.js"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import { fetchIpsecState } from "../services/ipsec-live.js"
|
||||
const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters<typeof fetchIpsecState>[0]
|
||||
const original = MikrotikClient.fromServer
|
||||
let writes = 0, records = 0, fault = ""
|
||||
const identities = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }]
|
||||
MikrotikClient.fromServer = () => ({ get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record<string,string>) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient
|
||||
const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createIpsecRoutes({ findServer: async () => server, recordRevision: async () => { records++; return {created:false,revision:{} as never} } }), { prefix: "/api" })
|
||||
const patch = (payload: Record<string, unknown>) => app.inject({ method: "PATCH", url: "/api/ipsec/users/1/identity:*I", payload })
|
||||
try {
|
||||
for (const body of [{name:"New",staticIp:"198.18.81.249"},{name:"Other"},{remoteId:"key-id:other"}]) { writes=0; const r=await patch(body); assert.equal(r.statusCode,409); assert.equal(writes,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Alice") }
|
||||
writes=records=0; fault="partial"; let r=await patch({name:"New",psk:"new-test-secret"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"partial"); assert.equal(r.json().confirmedWrites,1); assert.equal(records,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=New")
|
||||
writes=records=0; fault="lost-ack"; r=await patch({name:"Unknown"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"unknown"); assert.equal(r.json().confirmedWrites,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Unknown"); assert.equal(records,0)
|
||||
writes=records=0; fault=""; r=await patch({remoteId:"fresh"}); assert.equal(r.statusCode,200); assert.equal(identities[0]!["remote-id"],"key-id:fresh"); assert.equal(writes,1); assert.equal(records,1)
|
||||
} finally { await app.close(); MikrotikClient.fromServer=original }
|
||||
console.log("IPsec HTTP mutations: no-write conflicts, acknowledged partial failure, applied-but-lost ack and remote ID normalization")
|
||||
|
||||
+65
-26
@@ -31,6 +31,7 @@ import {
|
||||
selectIke2Peers,
|
||||
selectSharedIke2Identity,
|
||||
userModeConfigName,
|
||||
normalizeIpsecRemoteId,
|
||||
} from "../services/ipsec-config.js"
|
||||
import {
|
||||
ensureIpsecPeer,
|
||||
@@ -78,6 +79,7 @@ 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 { trackIpsecMutation, type IpsecMutationProgress } from "../services/ipsec-operation.js"
|
||||
import { z } from "zod"
|
||||
|
||||
|
||||
@@ -93,11 +95,14 @@ function rosIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function errReply(reply: FastifyReply, e: unknown) {
|
||||
function errReply(reply: FastifyReply, e: unknown, progress?: IpsecMutationProgress) {
|
||||
if (e instanceof IpsecRestoreConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
|
||||
if (e instanceof IpsecHistoryError) return reply.status(502).send({ error: e.message, state: "applied", historyRecorded: false })
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
const receipt = progress?.receipt()
|
||||
const guidance = receipt?.state === "applied" ? "Операция выполнена, но итоговый ответ не получен."
|
||||
: receipt?.state === "partial" ? "Часть изменений применена." : receipt?.state === "unknown" ? "Результат операции не подтверждён." : ""
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}${guidance ? `. ${guidance} Обновите данные перед повтором` : ""}`, ...receipt })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +211,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
}
|
||||
const server = await getEnabledIpsecServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const caCert = await ensureCaCertificate(client, body.caDaysValid)
|
||||
const serverCert = await ensureServerCertificate(client, {
|
||||
@@ -225,11 +231,12 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
if (body.createNatRule) {
|
||||
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
|
||||
}
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
const state = await fetchIpsecState(server)
|
||||
return reply.status(201).send(mapServerSummary(state, mapClients(state)))
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -239,13 +246,15 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const removeCertificates = q.removeCertificates !== "false"
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
await removeManagedIpsecServer(client, removeCertificates)
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -257,7 +266,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const body = parsed.data
|
||||
const server = await getEnabledIpsecServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const ike2Peers = selectIke2Peers(state.peers)
|
||||
@@ -289,6 +299,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
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()
|
||||
if (body.authMethod === "pre-shared-key" && state.identities.some(i => i.peer === peerName && i["auth-method"] === "pre-shared-key" && i["remote-id"] === normalizeIpsecRemoteId(body.remoteId ?? body.name))) return reply.status(409).send({ error: "Remote ID уже используется клиентом этого peer" })
|
||||
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||
|
||||
@@ -319,7 +330,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
daysValid: body.daysValid ?? 1825,
|
||||
})
|
||||
if (issued.existed) {
|
||||
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` })
|
||||
const receipt = progress.receipt()
|
||||
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует${receipt ? ". Часть изменений применена; обновите данные перед повтором" : ""}`, ...receipt })
|
||||
}
|
||||
certName = issued.certName
|
||||
}
|
||||
@@ -360,6 +372,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
})
|
||||
}
|
||||
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
const fresh = await fetchIpsecState(server)
|
||||
const clients = mapClients(fresh)
|
||||
@@ -368,7 +381,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
) ?? clients.find((c) => c.certificateName === certName)
|
||||
return reply.status(201).send({ client: created ?? null, bundle })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -382,7 +395,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
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)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const targetRosId = rosIdParam(rosId)
|
||||
@@ -407,6 +421,16 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const sharedMcName = (sharedMc?.name ?? "").trim()
|
||||
const newName = d.name?.trim() || oldName
|
||||
const normalizedRemoteId = d.remoteId === undefined ? undefined : normalizeIpsecRemoteId(d.remoteId)
|
||||
|
||||
if (d.name && newName !== oldName && mapClients(state).some(c => c.name === newName && (identity ? c.rosId !== `identity:${identity[".id"]}` : c.certName !== certRow?.name))) return reply.status(409).send({ error: "Имя уже используется другим клиентом" })
|
||||
if (identity && normalizedRemoteId !== undefined && state.identities.some(i => i[".id"] !== identity[".id"] && i.peer === identity.peer && i["auth-method"] === "pre-shared-key" && i["remote-id"] === normalizedRemoteId)) return reply.status(409).send({ error: "Remote ID уже используется клиентом этого peer" })
|
||||
|
||||
// Validate conflicts before changing even the display name.
|
||||
if (d.staticIp) {
|
||||
const currentMc = (identity?.["mode-config"] ?? "").trim()
|
||||
if (takenStaticIps(state.modeConfigs.filter(m => !currentMc || m.name !== currentMc)).includes(d.staticIp.trim())) return reply.status(409).send({ error: "IP уже назначен другому клиенту" })
|
||||
}
|
||||
|
||||
if (d.name && d.name !== oldName) {
|
||||
if (identity) {
|
||||
@@ -440,6 +464,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const name = userModeConfigName(newName)
|
||||
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
|
||||
await patchIdentity(client, identity[".id"]!, { "mode-config": name })
|
||||
if (currentMc && currentMc !== sharedMcName && currentMc !== name) await deleteUserModeConfig(client, currentMc)
|
||||
}
|
||||
} else if (!identity && certRow && d.staticIp) {
|
||||
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
|
||||
@@ -466,16 +491,17 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
if (identity) {
|
||||
const patch: Record<string, string> = {}
|
||||
if (d.psk) patch.secret = d.psk
|
||||
if (d.remoteId !== undefined) patch["remote-id"] = d.remoteId
|
||||
if (d.remoteId !== undefined) patch["remote-id"] = normalizedRemoteId!
|
||||
if (d.disabled === true) patch.disabled = "yes"
|
||||
if (d.disabled === false) patch.disabled = "no"
|
||||
if (Object.keys(patch).length) await patchIdentity(client, identity[".id"]!, patch)
|
||||
}
|
||||
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -485,7 +511,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const removeCertificate = q.removeCertificate !== "false"
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const targetRosId = rosIdParam(rosId)
|
||||
@@ -518,10 +545,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
await client.removeCertificate(certName)
|
||||
}
|
||||
}
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -534,7 +562,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const d = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
|
||||
@@ -548,10 +577,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
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)
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -560,7 +590,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const q = req.query as { force?: string }
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
|
||||
@@ -576,10 +607,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
})
|
||||
}
|
||||
await deletePeer(client, rosIdParam(rosId))
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -592,7 +624,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const q = req.query as { force?: string }
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const name = parsed.data.name
|
||||
@@ -611,10 +644,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
})
|
||||
}
|
||||
await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`)
|
||||
progress.complete()
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -627,7 +661,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const body = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const target = resolveIpsecClientTarget(state, rosIdParam(rosId))
|
||||
@@ -674,7 +709,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
}
|
||||
return reply.send(bundle)
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -688,7 +723,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
const { name, passphrase } = parsed.data
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||||
const client = progress.client
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
|
||||
@@ -707,7 +743,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
})
|
||||
return reply.send(bundle)
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -730,6 +766,7 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
requestedServerId: parseDbServerId(body?.serverId),
|
||||
})
|
||||
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
|
||||
let progress: IpsecMutationProgress | undefined
|
||||
try {
|
||||
const desired = parseIpsecSnapshot(loaded.row.payload)
|
||||
const state = await fetchIpsecRestoreState(loaded.server)
|
||||
@@ -746,12 +783,14 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
|
||||
nat: state.nat,
|
||||
policies: state.policies,
|
||||
})
|
||||
await executeRosOps(state.client, ops)
|
||||
progress = trackIpsecMutation(state.client)
|
||||
await executeRosOps(progress.client, ops)
|
||||
progress.complete()
|
||||
await recordIpsec(loaded.server, "rollback")
|
||||
const result = await listIpsec({ serverId: String(loaded.server.id) })
|
||||
return reply.send({ ok: true, ...result })
|
||||
} catch (e) {
|
||||
return errReply(reply, e)
|
||||
return errReply(reply, e, progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -311,3 +311,8 @@ export function buildClientInstructions(args: {
|
||||
"Адрес выдаётся автоматически при подключении (mode-config).",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function normalizeIpsecRemoteId(value: string): string {
|
||||
const id = value.trim()
|
||||
return id && !id.includes(":") && !["auto", "ignore"].includes(id) ? `key-id:${id}` : id
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { trackIpsecMutation } from "./ipsec-operation.js"
|
||||
import { MikrotikError, type MikrotikClient } from "./mikrotik.js"
|
||||
const rejected = new MikrotikError(403, "/ip/ipsec/peer", "forbidden")
|
||||
for (const steps of [0, 1]) for (const failure of [rejected, new Error("socket closed"), new MikrotikError(500, "/ip/ipsec/peer", "server error")]) {
|
||||
let attempts = 0
|
||||
const client = { patch: async () => { attempts++; if (attempts > steps) throw failure }, put: async () => {}, delete: async () => {}, post: async () => {} } as unknown as MikrotikClient
|
||||
const tracked = trackIpsecMutation(client)
|
||||
if (steps) await tracked.client.patch("/own", {})
|
||||
await assert.rejects(tracked.client.patch("/own", {}))
|
||||
assert.equal(tracked.receipt()?.state, failure === rejected ? steps ? "partial" : undefined : "unknown")
|
||||
assert.equal(tracked.receipt()?.confirmedWrites ?? 0, steps)
|
||||
}
|
||||
const client = { patch: async () => {}, put: async () => {}, delete: async () => {}, post: async () => {}, removeCertificate: async function () { await this.delete("/certificate/own") } } as unknown as MikrotikClient
|
||||
const tracked = trackIpsecMutation(client)
|
||||
await tracked.client.removeCertificate("own")
|
||||
assert.equal(tracked.receipt()?.confirmedWrites, 1)
|
||||
tracked.complete(); assert.deepEqual(tracked.receipt(), { state: "applied", confirmedWrites: 1, needsReview: true })
|
||||
console.log("IPsec progress: native 4xx, partial writes, transport/5xx uncertainty, nested methods and completed operation")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||
|
||||
/** Request-local evidence only: never claims that an unacknowledged request failed to apply. */
|
||||
export function trackIpsecMutation(client: MikrotikClient) {
|
||||
let confirmedWrites = 0, uncertainWrite = false, completed = false
|
||||
const methods = client as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>
|
||||
for (const method of ["put", "patch", "delete", "post"]) {
|
||||
const original = methods[method]!.bind(client)
|
||||
methods[method] = async (...args) => {
|
||||
try { const result = await original(...args); confirmedWrites++; return result }
|
||||
catch (error) {
|
||||
if (!(error instanceof MikrotikError && error.statusCode >= 400 && error.statusCode < 500)) uncertainWrite = true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
client,
|
||||
complete: () => { completed = true },
|
||||
receipt: () => {
|
||||
const state = completed ? "applied" : uncertainWrite ? "unknown" : confirmedWrites ? "partial" : undefined
|
||||
return state ? { state, confirmedWrites, needsReview: true } : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
export type IpsecMutationProgress = ReturnType<typeof trackIpsecMutation>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import {
|
||||
import { normalizeIpsecRemoteId,
|
||||
ipsecManagedComment,
|
||||
isManagedIpsecModeConfig,
|
||||
selectIke2Peers,
|
||||
@@ -197,7 +197,7 @@ export function identityRosBody(f: IdentityFields): Record<string, string> {
|
||||
"remote-certificate": f.remoteCertificate,
|
||||
"match-by": f.authMethod === "certificate" && f.remoteCertificate ? "certificate" : undefined,
|
||||
secret: f.secret,
|
||||
"remote-id": f.remoteId && !f.remoteId.includes(":") && !["auto", "ignore"].includes(f.remoteId) ? `key-id:${f.remoteId}` : f.remoteId,
|
||||
"remote-id": f.remoteId === undefined ? undefined : normalizeIpsecRemoteId(f.remoteId),
|
||||
"mode-config": f.modeConfig,
|
||||
"generate-policy": "port-strict",
|
||||
comment: f.comment,
|
||||
|
||||
Reference in new issue
Block a user