Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m18s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 45s
Docker images / backend-image (push) Successful in 2m43s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Co-authored-by: Cursor <cursoragent@cursor.com>
755 lines
33 KiB
TypeScript
755 lines
33 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_CA_CERT,
|
|
IPSEC_COMMON_NAME,
|
|
IPSEC_SERVER_CERT,
|
|
buildClientInstructions,
|
|
buildSswanConfig,
|
|
clientCertName,
|
|
identityDisplayName,
|
|
ipsecManagedComment,
|
|
ipsecUserComment,
|
|
isIke2RemoteAccessIdentity,
|
|
isIpsecManagedComment,
|
|
parseIpsecUserComment,
|
|
poolRangesFromCidr,
|
|
resolveIke2CaName,
|
|
resolveIke2ServerCert,
|
|
selectIke2Peers,
|
|
selectSharedIke2Identity,
|
|
userModeConfigName,
|
|
} from "../services/ipsec-config.js"
|
|
import {
|
|
ensureIpsecPeer,
|
|
ensureIpsecPool,
|
|
ensureIpsecProfile,
|
|
ensureIpsecProposal,
|
|
ensureNatRule,
|
|
ensurePolicyTemplate,
|
|
ensureSharedModeConfig,
|
|
putUserModeConfig,
|
|
deleteUserModeConfig,
|
|
putIdentity,
|
|
patchIdentity,
|
|
deleteIdentity,
|
|
patchPeer,
|
|
deletePeer,
|
|
ensureSharedIke2Identity,
|
|
listByPath,
|
|
} from "../services/ipsec-ros.js"
|
|
import {
|
|
ensureCaCertificate,
|
|
ensureServerCertificate,
|
|
exportCertificateP12ByName,
|
|
findCertificate,
|
|
issueClientCertificate,
|
|
} from "../services/ipsec-ca.js"
|
|
import {
|
|
captureIpsecSnapshot,
|
|
fetchIpsecRestoreState,
|
|
fetchIpsecState,
|
|
getEnabledIpsecServerById,
|
|
listIpsec,
|
|
mapClients,
|
|
mapServerSummary,
|
|
} from "../services/ipsec-live.js"
|
|
import {
|
|
captureAndAppendRevision,
|
|
listRevisions,
|
|
loadRevisionForRestore,
|
|
type ConfigRevisionSource,
|
|
} from "../services/config-revisions.js"
|
|
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
|
|
import { executeRosOps } from "../services/ros-ops.js"
|
|
import { parseDbServerId } from "../utils/server-id.js"
|
|
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")
|
|
}
|
|
|
|
function serverIdParam(v: string): string {
|
|
return decodeURIComponent(v)
|
|
}
|
|
|
|
function rosIdParam(v: string): string {
|
|
return decodeURIComponent(v)
|
|
}
|
|
|
|
function errReply(reply: FastifyReply, e: unknown) {
|
|
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
|
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
|
}
|
|
|
|
/**
|
|
* 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() })
|
|
}
|
|
|
|
async function recordIpsec(
|
|
server: NonNullable<Awaited<ReturnType<typeof getEnabledIpsecServerById>>>,
|
|
source: ConfigRevisionSource,
|
|
) {
|
|
await captureAndAppendRevision({
|
|
serverId: server.id,
|
|
section: "ipsec",
|
|
source,
|
|
capture: () => captureIpsecSnapshot(server),
|
|
})
|
|
}
|
|
|
|
/** Существующие статические 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 ipsecRoutes: FastifyPluginAsyncZod = 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) 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 client = MikrotikClient.fromServer(server)
|
|
try {
|
|
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)
|
|
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
|
|
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
|
|
if (body.createNatRule) {
|
|
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
|
|
}
|
|
await recordIpsec(server, "apply")
|
|
const state = await fetchIpsecState(server)
|
|
return reply.status(201).send(mapServerSummary(state, mapClients(state)))
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
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 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 recordIpsec(server, "apply")
|
|
return reply.send({ ok: true })
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
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 client = MikrotikClient.fromServer(server)
|
|
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]
|
|
?? state.peers[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)
|
|
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
|
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
|
|
return reply.status(400).send({
|
|
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
|
})
|
|
}
|
|
const peerName = (peer.name ?? "").trim()
|
|
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 (!IPV4_RE.test(ip)) 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) {
|
|
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` })
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
|
|
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
|
|
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)
|
|
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 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
|
|
|
|
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(),
|
|
})
|
|
}
|
|
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем)
|
|
if (certRow?.[".id"] && managedIdentity) {
|
|
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.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 (!IPV4_RE.test(ip)) 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 })
|
|
}
|
|
} 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}` })
|
|
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"] = d.remoteId
|
|
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)
|
|
}
|
|
|
|
await recordIpsec(server, "apply")
|
|
return reply.send({ ok: true })
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
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 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)
|
|
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
|
const managed = identity
|
|
? isIpsecManagedComment(identity.comment)
|
|
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
|
const userName = identity ? (parseIpsecUserComment(identity.comment) ?? "") : ""
|
|
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()
|
|
|
|
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))
|
|
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim()
|
|
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
|
|
if (certName && certName !== caName && certName !== serverCertName) {
|
|
await client.removeCertificate(certName).catch(() => undefined)
|
|
}
|
|
}
|
|
await recordIpsec(server, "apply")
|
|
return reply.send({ ok: true })
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
app.patch("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const parsed = ipsecPeerPatchSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const d = parsed.data
|
|
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
const state = await fetchIpsecState(server)
|
|
const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
|
|
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
|
|
const body: Record<string, string> = {}
|
|
if (d.name !== undefined) body.name = d.name
|
|
if (d.address !== undefined) body.address = d.address
|
|
if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode
|
|
if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no"
|
|
if (d.certificate !== undefined) body.certificate = d.certificate
|
|
if (d.profile !== undefined) body.profile = d.profile
|
|
if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no"
|
|
if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body)
|
|
await recordIpsec(server, "apply")
|
|
return reply.send({ ok: true })
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
app.delete("/ipsec/peers/:serverId/:rosId", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const q = req.query as { force?: string }
|
|
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
const state = await fetchIpsecState(server)
|
|
const peer = state.peers.find((p) => String(p[".id"] ?? "") === rosIdParam(rosId))
|
|
if (!peer) return reply.status(404).send({ error: "Peer не найден" })
|
|
const peerName = (peer.name ?? "").trim()
|
|
const linked = state.identities
|
|
.filter((i) => (i.peer ?? "").trim() === peerName)
|
|
.map((i) => identityDisplayName(i, state.certs))
|
|
if (linked.length > 0 && q.force !== "true") {
|
|
return reply.status(409).send({
|
|
error: `На peer «${peerName}» ссылаются identity: ${linked.join(", ")}. Удаление разорвёт их.`,
|
|
identities: linked,
|
|
})
|
|
}
|
|
await deletePeer(client, rosIdParam(rosId))
|
|
await recordIpsec(server, "apply")
|
|
return reply.send({ ok: true })
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
app.delete("/ipsec/certs/:serverId", async (req, reply) => {
|
|
const { serverId } = req.params as { serverId: string }
|
|
const parsed = ipsecCertDeleteRequestSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const q = req.query as { force?: string }
|
|
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
const state = await fetchIpsecState(server)
|
|
const name = parsed.data.name
|
|
const cert = state.certs.find((c) => String(c.name ?? "").trim() === name)
|
|
if (!cert?.[".id"]) return reply.status(404).send({ error: `Сертификат ${name} не найден` })
|
|
const usedBy = [
|
|
...state.peers.filter((p) => (p.certificate ?? "").trim() === name).map((p) => `peer ${(p.name ?? "").trim()}`),
|
|
...state.identities
|
|
.filter((i) => (i.certificate ?? "").trim() === name || (i["remote-certificate"] ?? "").trim() === name)
|
|
.map((i) => `identity ${identityDisplayName(i, state.certs)}`),
|
|
]
|
|
if (usedBy.length > 0 && q.force !== "true") {
|
|
return reply.status(409).send({
|
|
error: `Сертификат «${name}» используется: ${usedBy.join(", ")}.`,
|
|
usedBy,
|
|
})
|
|
}
|
|
await client.delete(`/certificate/${encodeURIComponent(cert[".id"])}`)
|
|
await recordIpsec(server, "apply")
|
|
return reply.send({ ok: true })
|
|
} catch (e) {
|
|
return errReply(reply, e)
|
|
}
|
|
})
|
|
|
|
app.post("/ipsec/users/:serverId/:rosId/cert", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const parsed = ipsecCertExportRequestSchema.safeParse({ ...(req.body as object), serverId, clientId: rosId })
|
|
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 client = MikrotikClient.fromServer(server)
|
|
try {
|
|
const state = await fetchIpsecState(server)
|
|
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
|
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
|
const 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)
|
|
}
|
|
})
|
|
|
|
/** Экспорт .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 client = MikrotikClient.fromServer(server)
|
|
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)
|
|
}
|
|
})
|
|
|
|
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 })
|
|
try {
|
|
const desired = parseIpsecSnapshot(loaded.row.payload)
|
|
const state = await fetchIpsecRestoreState(loaded.server)
|
|
const ops = planIpsecRestore(desired, {
|
|
peers: state.peers,
|
|
identities: state.identities,
|
|
modeConfigs: state.modeConfigs,
|
|
pools: state.pools,
|
|
nat: state.nat,
|
|
})
|
|
await executeRosOps(state.client, ops)
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
export default ipsecRoutes
|