824 lines
42 KiB
TypeScript
824 lines
42 KiB
TypeScript
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||
import type { FastifyReply } from "fastify"
|
||
import {
|
||
IPSEC_MIN_PASSPHRASE,
|
||
ipsecCertDeleteRequestSchema,
|
||
ipsecCertExportByNameRequestSchema,
|
||
ipsecCertExportRequestSchema,
|
||
ipsecInitRequestSchema,
|
||
ipsecPeerPatchSchema,
|
||
ipsecUserCreateRequestSchema,
|
||
ipsecUserPatchSchema,
|
||
type IpsecCertBundle,
|
||
type IpsecClientDto,
|
||
} from "@mmapp/contracts/ipsec"
|
||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||
import {
|
||
IPSEC_COMMON_NAME,
|
||
IPSEC_POLICY_GROUP,
|
||
IPSEC_SERVER_CERT,
|
||
buildClientInstructions,
|
||
buildSswanConfig,
|
||
clientCertName,
|
||
identityDisplayName,
|
||
ipsecManagedComment,
|
||
ipsecUserComment,
|
||
isIke2RemoteAccessIdentity,
|
||
isIpsecManagedComment,
|
||
parseIpsecUserComment,
|
||
poolRangesFromCidr,
|
||
resolveIke2CaName,
|
||
resolveIke2ServerCert,
|
||
selectIke2Peers,
|
||
selectSharedIke2Identity,
|
||
userModeConfigName,
|
||
normalizeIpsecRemoteId,
|
||
} from "../services/ipsec-config.js"
|
||
import {
|
||
ensureIpsecPeer,
|
||
ensureIpsecPool,
|
||
ensureIpsecProfile,
|
||
ensureIpsecProposal,
|
||
ensureNatRule,
|
||
resolveIpsecNatInterface,
|
||
IpsecNatInterfaceConflict,
|
||
IpsecPolicyGroupConflict,
|
||
ipsecPolicyGroupExists,
|
||
listByPath,
|
||
ensurePolicyTemplate,
|
||
ensureSharedModeConfig,
|
||
putUserModeConfig,
|
||
deleteUserModeConfig,
|
||
putIdentity,
|
||
patchIdentity,
|
||
deleteIdentity,
|
||
patchPeer,
|
||
deletePeer,
|
||
ensureSharedIke2Identity,
|
||
} from "../services/ipsec-ros.js"
|
||
import {
|
||
ensureCaCertificate,
|
||
ensureServerCertificate,
|
||
exportCertificateP12ByName,
|
||
findCertificate,
|
||
issueClientCertificate,
|
||
} from "../services/ipsec-ca.js"
|
||
import {
|
||
captureIpsecSnapshot,
|
||
fetchIpsecRestoreState,
|
||
fetchIpsecState,
|
||
getEnabledIpsecServerById,
|
||
listIpsec,
|
||
mapClients,
|
||
resolveIpsecClientTarget,
|
||
mapServerSummary,
|
||
} from "../services/ipsec-live.js"
|
||
import {
|
||
captureAndAppendRevision,
|
||
listRevisions,
|
||
loadRevisionForRestore,
|
||
type ConfigRevisionSource,
|
||
} from "../services/config-revisions.js"
|
||
import { IpsecRestoreConflict, 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 { trackIpsecMutation, type IpsecMutationProgress } from "../services/ipsec-operation.js"
|
||
import { z } from "zod"
|
||
|
||
|
||
function generatePassphrase(): string {
|
||
return randomBytes(9).toString("base64url")
|
||
}
|
||
|
||
function serverIdParam(v: string): string {
|
||
return decodeURIComponent(v)
|
||
}
|
||
|
||
function rosIdParam(v: string): string {
|
||
return decodeURIComponent(v)
|
||
}
|
||
|
||
function errReply(reply: FastifyReply, e: unknown, progress?: IpsecMutationProgress) {
|
||
if (e instanceof IpsecNatInterfaceConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
|
||
if (e instanceof IpsecPolicyGroupConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
|
||
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)
|
||
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 })
|
||
}
|
||
|
||
/**
|
||
* 400 по невалидному телу. Для короткого пароля .p12 — понятный текст вместо generic-сообщения:
|
||
* RouterOS отклоняет `export-passphrase` короче 8 символов.
|
||
*/
|
||
function badBodyReply(reply: FastifyReply, error: z.ZodError) {
|
||
const shortPassphrase = error.issues.some(
|
||
(issue) => issue.path[0] === "passphrase" && issue.code === "too_small",
|
||
)
|
||
if (shortPassphrase) {
|
||
return reply.status(400).send({
|
||
error: `Пароль архива .p12 должен быть не короче ${IPSEC_MIN_PASSPHRASE} символов (требование RouterOS)`,
|
||
})
|
||
}
|
||
return reply.status(400).send({ error: "Некорректное тело запроса", details: error.flatten() })
|
||
}
|
||
|
||
class IpsecHistoryError extends Error {
|
||
constructor() {
|
||
super("Операция выполнена на устройстве, но сохранение истории не подтверждено. Обновите данные перед повтором")
|
||
}
|
||
}
|
||
|
||
async function recordIpsecDefault(
|
||
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
|
||
source: ConfigRevisionSource,
|
||
record: typeof captureAndAppendRevision,
|
||
) {
|
||
const result = await record({
|
||
serverId: server.id,
|
||
section: "ipsec",
|
||
source,
|
||
capture: () => captureIpsecSnapshot(server),
|
||
})
|
||
if (!result && source !== "observed") throw new IpsecHistoryError()
|
||
}
|
||
|
||
/** Существующие статические IP клиентов (персональные mode-config). */
|
||
function takenStaticIps(modeConfigs: Array<{ name?: string; address?: string; "address-prefix"?: string }>): string[] {
|
||
return modeConfigs
|
||
.filter((m) => (m.name ?? "").startsWith("mc-ipsec-"))
|
||
.map((m) => String(m.address ?? m["address-prefix"] ?? "").replace(/\/\d+$/, "").trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
async function buildCertBundle(
|
||
client: MikrotikClient,
|
||
args: { userName: string; certName?: string; serverEndpoint: string; passphrase: string; dns?: string },
|
||
): Promise<IpsecCertBundle> {
|
||
const certName = args.certName?.trim() || clientCertName(args.userName)
|
||
const { fileName, content, passphrase } = await exportCertificateP12ByName(client, certName, args.passphrase)
|
||
const p12B64 = content.toString("base64")
|
||
return {
|
||
user: args.userName,
|
||
serverEndpoint: args.serverEndpoint,
|
||
filename: fileName,
|
||
contentB64: p12B64,
|
||
mime: "application/x-pkcs12",
|
||
passphrase,
|
||
sswanFilename: `${certName}.sswan`,
|
||
sswanContent: buildSswanConfig({
|
||
name: `IKEv2 ${args.serverEndpoint}`,
|
||
serverEndpoint: args.serverEndpoint,
|
||
serverId: args.serverEndpoint,
|
||
p12B64,
|
||
}),
|
||
instructions: buildClientInstructions({
|
||
userName: args.userName,
|
||
serverEndpoint: args.serverEndpoint,
|
||
p12Filename: fileName,
|
||
passphrase,
|
||
dns: args.dns,
|
||
}),
|
||
}
|
||
}
|
||
|
||
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
||
|
||
const ipsecDefaults = { findServer: getEnabledIpsecServerById, recordRevision: captureAndAppendRevision }
|
||
|
||
export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAsyncZod {
|
||
const getEnabledIpsecServerById = dependencies.findServer
|
||
const recordIpsec = (server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>, source: ConfigRevisionSource) => recordIpsecDefault(server, source, dependencies.recordRevision)
|
||
return async (app) => {
|
||
app.get("/ipsec", async (req, reply) => {
|
||
const q = req.query as { serverId?: string }
|
||
const result = await listIpsec({ serverId: q.serverId })
|
||
const sid = parseDbServerId(q.serverId)
|
||
if (sid !== null) {
|
||
const server = await getEnabledIpsecServerById(sid)
|
||
if (server && !result.failures?.some(f => f.serverId === String(sid))) await recordIpsec(server, "observed")
|
||
}
|
||
return reply.send(result)
|
||
})
|
||
|
||
app.post("/ipsec/server/init", async (req, reply) => {
|
||
const parsed = ipsecInitRequestSchema.safeParse(req.body ?? {})
|
||
if (!parsed.success) {
|
||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||
}
|
||
const body = parsed.data
|
||
const ranges = poolRangesFromCidr(body.poolCidr)
|
||
if (!ranges) {
|
||
return reply.status(400).send({ error: `Некорректная подсеть пула: ${body.poolCidr}` })
|
||
}
|
||
const server = await getEnabledIpsecServerById(body.serverId)
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||
const client = progress.client
|
||
try {
|
||
const natInterface = body.createNatRule ? await resolveIpsecNatInterface(client) : undefined
|
||
const groupExists = await ipsecPolicyGroupExists(client)
|
||
const caCert = await ensureCaCertificate(client, body.caDaysValid)
|
||
const serverCert = await ensureServerCertificate(client, {
|
||
serverEndpoint: body.serverEndpoint,
|
||
caCertName: caCert,
|
||
daysValid: body.serverDaysValid,
|
||
})
|
||
const comment = ipsecManagedComment("IKEv2 road-warrior")
|
||
await ensureIpsecProfile(client, IPSEC_COMMON_NAME, comment)
|
||
await ensureIpsecProposal(client, IPSEC_COMMON_NAME, comment)
|
||
await ensureIpsecPool(client, IPSEC_COMMON_NAME, ranges, comment)
|
||
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
|
||
if (!groupExists) await client.put("/ip/ipsec/policy/group", { name: IPSEC_POLICY_GROUP })
|
||
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"), policyTemplateGroup: IPSEC_POLICY_GROUP })
|
||
for (const identity of await listByPath(client, "/ip/ipsec/identity")) {
|
||
if (identity.peer === IPSEC_COMMON_NAME && isIpsecManagedComment(identity.comment) && (!identity["policy-template-group"] || identity["policy-template-group"] === "default")) await patchIdentity(client, identity[".id"]!, { "policy-template-group": IPSEC_POLICY_GROUP })
|
||
}
|
||
if (body.createNatRule) {
|
||
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"), natInterface!)
|
||
}
|
||
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, progress)
|
||
}
|
||
})
|
||
|
||
app.delete("/ipsec/server/:serverId", async (req, reply) => {
|
||
const { serverId } = req.params as { serverId: string }
|
||
const q = req.query as { removeCertificates?: string }
|
||
const removeCertificates = q.removeCertificates !== "false"
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
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, progress)
|
||
}
|
||
})
|
||
|
||
app.post("/ipsec/users", async (req, reply) => {
|
||
const parsed = ipsecUserCreateRequestSchema.safeParse(req.body ?? {})
|
||
if (!parsed.success) {
|
||
return badBodyReply(reply, parsed.error)
|
||
}
|
||
const body = parsed.data
|
||
const server = await getEnabledIpsecServerById(body.serverId)
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||
const client = progress.client
|
||
try {
|
||
const state = await fetchIpsecState(server)
|
||
const ike2Peers = selectIke2Peers(state.peers)
|
||
const peer = (body.peerName
|
||
? state.peers.find((p) => (p.name ?? "").trim() === body.peerName!.trim())
|
||
: undefined)
|
||
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
||
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||
?? ike2Peers[0]
|
||
if (!peer) {
|
||
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
||
}
|
||
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||
const ike2Identities = state.identities.filter(
|
||
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
|
||
)
|
||
const idMcName = ike2Identities
|
||
.map((i) => (i["mode-config"] ?? "").trim())
|
||
.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, 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()
|
||
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
|
||
|
||
if (body.authMethod === "pre-shared-key" && !body.psk) {
|
||
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
|
||
}
|
||
|
||
const sharedIdentity = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||
const sharedIdentityId = sharedIdentity?.[".id"] ? String(sharedIdentity[".id"]) : undefined
|
||
|
||
let modeConfig = (sharedMc?.name ?? "").trim()
|
||
if (body.staticIp) {
|
||
const ip = body.staticIp.trim()
|
||
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} уже назначен другому клиенту` })
|
||
}
|
||
modeConfig = userModeConfigName(body.name)
|
||
await putUserModeConfig(client, modeConfig, ip, ipsecManagedComment(`клиент ${body.name.trim()}`))
|
||
}
|
||
|
||
let certName: string | undefined
|
||
if (body.authMethod === "certificate") {
|
||
const issued = await issueClientCertificate(client, {
|
||
userName: body.name,
|
||
caCertName: caName!,
|
||
daysValid: body.daysValid ?? 1825,
|
||
})
|
||
if (issued.existed) {
|
||
const receipt = progress.receipt()
|
||
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует${receipt ? ". Часть изменений применена; обновите данные перед повтором" : ""}`, ...receipt })
|
||
}
|
||
certName = issued.certName
|
||
}
|
||
|
||
if (body.authMethod === "certificate" && !body.staticIp) {
|
||
// Гибрид: клиент «вешается» на общую identity peer — отдельная identity не нужна,
|
||
// RouterOS сам принимает любой клиентский серт, подписанный доверенным CA.
|
||
await ensureSharedIke2Identity(client, {
|
||
peerName,
|
||
serverCertName: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
|
||
modeConfigName: modeConfig,
|
||
comment: ipsecManagedComment("IKEv2 listener"),
|
||
})
|
||
} else {
|
||
// PSK или персональный статический IP: отдельная identity (match-by=certificate для cert).
|
||
// place-before гарантирует матчинг раньше общей listener-identity.
|
||
await putIdentity(client, {
|
||
peerName,
|
||
modeConfig,
|
||
comment: ipsecUserComment(body.name),
|
||
authMethod: body.authMethod,
|
||
certificate: body.authMethod === "certificate" ? String(serverCertRow?.name ?? IPSEC_SERVER_CERT) : undefined,
|
||
remoteCertificate: certName,
|
||
secret: body.psk,
|
||
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
|
||
placeBefore: sharedIdentityId,
|
||
})
|
||
}
|
||
|
||
let bundle: IpsecCertBundle | undefined
|
||
if (body.authMethod === "certificate" && certName) {
|
||
const passphrase = body.passphrase?.trim() || generatePassphrase()
|
||
bundle = await buildCertBundle(client, {
|
||
userName: body.name,
|
||
serverEndpoint,
|
||
passphrase,
|
||
dns,
|
||
})
|
||
}
|
||
|
||
progress.complete()
|
||
await recordIpsec(server, "apply")
|
||
const fresh = await fetchIpsecState(server)
|
||
const clients = mapClients(fresh)
|
||
const created: IpsecClientDto | undefined = clients.find(
|
||
(c) => c.name === body.name.trim(),
|
||
) ?? clients.find((c) => c.certificateName === certName)
|
||
return reply.status(201).send({ client: created ?? null, bundle })
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
app.patch("/ipsec/users/:serverId/:rosId", async (req, reply) => {
|
||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||
const parsed = ipsecUserPatchSchema.safeParse(req.body ?? {})
|
||
if (!parsed.success) {
|
||
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 progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||
const client = progress.client
|
||
try {
|
||
const state = await fetchIpsecState(server)
|
||
const targetRosId = rosIdParam(rosId)
|
||
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, state.identities)
|
||
const managedIdentity = identity
|
||
? isIpsecManagedComment(identity.comment)
|
||
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
||
const oldName = identity
|
||
? (parseIpsecUserComment(identity.comment) ?? identity.comment ?? "")
|
||
: String(certRow?.["common-name"] ?? certRow?.name ?? "").trim()
|
||
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) {
|
||
// managed: user=<name>; существующий RouterOS identity: обычный comment
|
||
await patchIdentity(client, identity[".id"]!, {
|
||
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||
})
|
||
}
|
||
// 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 })
|
||
}
|
||
}
|
||
|
||
if (identity && d.staticIp !== undefined) {
|
||
const currentMc = (identity["mode-config"] ?? "").trim()
|
||
if (d.staticIp == null) {
|
||
// вернуть выдачу из пула
|
||
if (sharedMcName) await patchIdentity(client, identity[".id"]!, { "mode-config": sharedMcName })
|
||
const personal = currentMc && currentMc !== sharedMcName ? currentMc : ""
|
||
if (personal) await deleteUserModeConfig(client, personal)
|
||
} else {
|
||
const ip = d.staticIp.trim()
|
||
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||
const others = takenStaticIps(
|
||
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
|
||
)
|
||
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||
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
|
||
const ip = d.staticIp.trim()
|
||
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]
|
||
if (!peer) return reply.status(400).send({ error: "На роутере нет IKEv2 peer для привязки клиента" })
|
||
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||
const mcName = userModeConfigName(newName)
|
||
await putUserModeConfig(client, mcName, ip, ipsecManagedComment(`клиент ${newName.trim()}`))
|
||
await putIdentity(client, {
|
||
peerName: (peer.name ?? "").trim(),
|
||
modeConfig: mcName,
|
||
comment: ipsecUserComment(newName),
|
||
authMethod: "certificate",
|
||
certificate: String(serverCertRow?.name ?? IPSEC_SERVER_CERT),
|
||
remoteCertificate: String(certRow.name ?? "").trim(),
|
||
placeBefore: shared?.[".id"] ? String(shared[".id"]) : undefined,
|
||
})
|
||
}
|
||
|
||
if (identity) {
|
||
const patch: Record<string, string> = {}
|
||
if (d.psk) patch.secret = d.psk
|
||
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, progress)
|
||
}
|
||
})
|
||
|
||
app.delete("/ipsec/users/:serverId/:rosId", async (req, reply) => {
|
||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||
const q = req.query as { removeCertificate?: string }
|
||
const removeCertificate = q.removeCertificate !== "false"
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||
const client = progress.client
|
||
try {
|
||
const state = await fetchIpsecState(server)
|
||
const targetRosId = rosIdParam(rosId)
|
||
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 sharedMcName = (state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)?.name ?? "").trim()
|
||
const personal = identity ? (identity["mode-config"] ?? "").trim() : ""
|
||
const remoteCert = identity
|
||
? (identity["remote-certificate"] ?? "").trim()
|
||
: String(certRow?.name ?? "").trim()
|
||
|
||
// Refuse before deleting the identity: a shared certificate must survive.
|
||
// A display name does not establish ownership of a certificate.
|
||
const deletionCertName = remoteCert
|
||
if (removeCertificate && deletionCertName && (
|
||
state.identities.some(i => i[".id"] !== identity?.[".id"] && ((i["remote-certificate"] ?? "").trim() === deletionCertName || (i.certificate ?? "").trim() === deletionCertName))
|
||
|| state.peers.some(p => (p.certificate ?? "").trim() === deletionCertName)
|
||
)) return reply.status(409).send({ error: "Сертификат используется другой identity или peer. Удалите только выбранную identity, сохранив сертификат" })
|
||
|
||
if (identity) {
|
||
await deleteIdentity(client, identity[".id"]!)
|
||
if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal)
|
||
}
|
||
if (removeCertificate) {
|
||
// удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
|
||
const ike2Peers = selectIke2Peers(state.peers)
|
||
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
|
||
if (certName && certName !== caName && certName !== serverCertName) {
|
||
await client.removeCertificate(certName)
|
||
}
|
||
}
|
||
progress.complete()
|
||
await recordIpsec(server, "apply")
|
||
return reply.send({ ok: true })
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
app.patch("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
|
||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||
const parsed = ipsecPeerPatchSchema.safeParse(req.body ?? {})
|
||
if (!parsed.success) {
|
||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||
}
|
||
const d = parsed.data
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const 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))
|
||
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
|
||
const body: Record<string, string> = {}
|
||
if (d.name !== undefined) body.name = d.name
|
||
if (d.address !== undefined) body.address = d.address
|
||
if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode
|
||
if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no"
|
||
if (d.certificate !== undefined) 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)
|
||
progress.complete()
|
||
await recordIpsec(server, "apply")
|
||
return reply.send({ ok: true })
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
app.delete("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
|
||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||
const q = req.query as { force?: string }
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const 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))
|
||
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
|
||
const peerName = (peer.name ?? "").trim()
|
||
const linked = state.identities
|
||
.filter((i) => (i.peer ?? "").trim() === peerName)
|
||
.map((i) => identityDisplayName(i, state.certs))
|
||
if (linked.length > 0 && q.force !== "true") {
|
||
return reply.status(409).send({
|
||
error: `На peer «${peerName}» ссылаются identity: ${linked.join(", ")}. Удаление разорвёт их.`,
|
||
identities: linked,
|
||
})
|
||
}
|
||
await deletePeer(client, rosIdParam(rosId))
|
||
progress.complete()
|
||
await recordIpsec(server, "apply")
|
||
return reply.send({ ok: true })
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
app.delete("/ipsec/certs/:serverId", async (req, reply) => {
|
||
const { serverId } = req.params as { serverId: string }
|
||
const parsed = ipsecCertDeleteRequestSchema.safeParse(req.body ?? {})
|
||
if (!parsed.success) {
|
||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||
}
|
||
const q = req.query as { force?: string }
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||
const client = progress.client
|
||
try {
|
||
const state = await fetchIpsecState(server)
|
||
const name = parsed.data.name
|
||
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
|
||
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
|
||
const usedBy = [
|
||
...state.peers.filter((p) => (p.certificate ?? "").trim() === name).map((p) => `peer ${(p.name ?? "").trim()}`),
|
||
...state.identities
|
||
.filter((i) => (i.certificate ?? "").trim() === name || (i["remote-certificate"] ?? "").trim() === name)
|
||
.map((i) => `identity ${identityDisplayName(i, state.certs)}`),
|
||
]
|
||
if (usedBy.length > 0 && q.force !== "true") {
|
||
return reply.status(409).send({
|
||
error: `Сертификат «${name}» используется: ${usedBy.join(", ")}.`,
|
||
usedBy,
|
||
})
|
||
}
|
||
await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`)
|
||
progress.complete()
|
||
await recordIpsec(server, "apply")
|
||
return reply.send({ ok: true })
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
app.post("/ipsec/users/:serverId/:rosId/cert", async (req, reply) => {
|
||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
|
||
if (!parsed.success) {
|
||
return badBodyReply(reply, parsed.error)
|
||
}
|
||
const body = parsed.data
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
|
||
const client = progress.client
|
||
try {
|
||
const state = await fetchIpsecState(server)
|
||
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-клиент" })
|
||
const certName = identity["remote-certificate"]?.trim() || clientCertName(userName)
|
||
if (!(await findCertificate(client, certName))) {
|
||
return reply.status(404).send({ error: `Сертификат ${certName} не найден на роутере` })
|
||
}
|
||
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
|
||
const serverEndpoint = String(serverCert?.["common-name"] ?? "").trim() || server.host
|
||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||
// export работает по имени сертификата (certName), не по userName
|
||
const { fileName, content, passphrase } = await client.exportCertificatePkcs12({
|
||
name: certName,
|
||
passphrase: body.passphrase,
|
||
})
|
||
const p12B64 = content.toString("base64")
|
||
const bundle: IpsecCertBundle = {
|
||
user: userName,
|
||
serverEndpoint,
|
||
filename: fileName,
|
||
contentB64: p12B64,
|
||
mime: "application/x-pkcs12",
|
||
passphrase,
|
||
sswanFilename: `${certName}.sswan`,
|
||
sswanContent: buildSswanConfig({
|
||
name: `IKEv2 ${serverEndpoint}`,
|
||
serverEndpoint,
|
||
serverId: serverEndpoint,
|
||
p12B64,
|
||
}),
|
||
instructions: buildClientInstructions({
|
||
userName,
|
||
serverEndpoint,
|
||
p12Filename: fileName,
|
||
passphrase,
|
||
dns,
|
||
}),
|
||
}
|
||
return reply.send(bundle)
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
|
||
app.post("/ipsec/certs/:serverId/export", async (req, reply) => {
|
||
const { serverId } = req.params as { serverId: string }
|
||
const parsed = ipsecCertExportByNameRequestSchema.safeParse(req.body ?? {})
|
||
if (!parsed.success) {
|
||
return badBodyReply(reply, parsed.error)
|
||
}
|
||
const { name, passphrase } = parsed.data
|
||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||
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)
|
||
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
|
||
const serverCertRow = resolveIke2ServerCert(state.certs, selectIke2Peers(state.peers))
|
||
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||
const userName = String(cert["common-name"] ?? "").trim() || name
|
||
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||
const bundle = await buildCertBundle(client, {
|
||
userName,
|
||
certName: name,
|
||
serverEndpoint,
|
||
passphrase,
|
||
dns,
|
||
})
|
||
return reply.send(bundle)
|
||
} catch (e) {
|
||
return errReply(reply, e, progress)
|
||
}
|
||
})
|
||
|
||
app.get("/ipsec/revisions", async (req, reply) => {
|
||
const q = req.query as { serverId?: string | number }
|
||
const serverId = parseDbServerId(q.serverId)
|
||
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||
const revisions = await listRevisions(serverId, "ipsec")
|
||
return reply.send({ revisions })
|
||
})
|
||
|
||
app.post("/ipsec/revisions/:id/restore", {
|
||
schema: { params: RevisionIdParamSchema },
|
||
}, async (req, reply) => {
|
||
const { id } = req.params
|
||
const body = req.body as { serverId?: string | number } | undefined
|
||
const loaded = await loadRevisionForRestore({
|
||
id,
|
||
section: "ipsec",
|
||
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)
|
||
const missingCertificates = desired.identities.flatMap(i => [i.certificate, i.remoteCertificate]).filter(n => n && !state.certificateNames.includes(n))
|
||
if (missingCertificates.length) return reply.status(409).send({ error: "На устройстве отсутствуют сертификаты ревизии. Откат не восстанавливает ключи PKI; изменений не было" })
|
||
const profiles = await state.client.get<unknown>("/ip/ipsec/profile")
|
||
if (!Array.isArray(profiles) || profiles.some(p => !p || typeof p.name !== "string")) throw new Error("Некорректный список IPsec profiles; откат не начат")
|
||
if (desired.peers.some(p => p.profile && !profiles.some(live => live.name === p.profile))) return reply.status(409).send({ error: "На устройстве отсутствует profile ревизии. Восстановите profile перед откатом; изменений не было" })
|
||
if (desired.identities.some(i => i.policyTemplateGroup)) {
|
||
const groups = await state.client.get<unknown>("/ip/ipsec/policy/group")
|
||
if (!Array.isArray(groups) || groups.some(g => !g || typeof g.name !== "string")) throw new Error("Некорректный список групп политик; откат не начат")
|
||
if (desired.identities.some(i => i.policyTemplateGroup && !groups.some(g => g.name === i.policyTemplateGroup))) return reply.status(409).send({ error: "Группа политик ревизии отсутствует. Восстановите её перед откатом; изменений не было" })
|
||
}
|
||
const ops = planIpsecRestore(desired, {
|
||
peers: state.peers,
|
||
identities: state.identities,
|
||
modeConfigs: state.modeConfigs,
|
||
pools: state.pools,
|
||
nat: state.nat,
|
||
policies: state.policies,
|
||
})
|
||
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, progress)
|
||
}
|
||
})
|
||
}
|
||
|
||
}
|
||
|
||
export default createIpsecRoutes()
|