Files
MikrotikManager/backend/src/services/ipsec-ros.ts
T
DenozordecandCursor 0e1524c600
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
fix(ipsec): клиенты через общий IKEv2-peer и отдельный клиентский сертификат
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 22:06:35 +07:00

259 lines
9.1 KiB
TypeScript

import type { MikrotikClient } from "./mikrotik.js"
import {
ipsecManagedComment,
selectIke2Peers,
selectSharedIke2Identity,
type Ike2IdentityLike,
type PeerLike,
} from "./ipsec-config.js"
export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v == null) continue
const s = String(v)
if (s === "") continue
out[k] = s
}
return out
}
type RosRow = Record<string, string | undefined>
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
const raw = await client.get<unknown>(path).catch(() => [])
return Array.isArray(raw) ? (raw as RosRow[]) : []
}
/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
export async function putOrPatchByName(
client: MikrotikClient,
path: string,
name: string,
body: Record<string, string>,
): Promise<"created" | "patched"> {
const rows = await listByPath(client, path)
const existing = rows.find((r) => String(r.name ?? "").trim() === name)
if (existing?.[".id"]) {
await client.patch(`${path}/${encodeURIComponent(existing[".id"])}`, body)
return "patched"
}
await client.put(path, body)
return "created"
}
async function findRosId(client: MikrotikClient, path: string, name: string): Promise<string | null> {
const rows = await listByPath(client, path)
return rows.find((r) => String(r.name ?? "").trim() === name)?.[".id"] ?? null
}
// ── инициализация IKEv2-сервера ─────────────────────────────────────────────
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
name,
"hash-algorithm": "sha256",
"enc-algorithm": "aes-256,aes-192,aes-128",
"dh-group": "modp2048,modp1536,modp1024",
"nat-traversal": "yes",
comment,
}))
}
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
name,
"auth-algorithms": "sha256,sha1",
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
"pfs-group": "modp2048",
comment,
}))
}
export async function ensureIpsecPool(client: MikrotikClient, name: string, ranges: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/pool", name, toRosBody({ name, ranges, comment }))
}
/** Общий mode-config: адрес клиентам из пула. */
export async function ensureSharedModeConfig(
client: MikrotikClient,
name: string,
poolName: string,
dns: string | undefined,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
"address-pool": poolName,
"static-dns": dns,
comment,
}))
}
export async function ensurePolicyTemplate(
client: MikrotikClient,
dstCidr: string,
proposalName: string,
comment: string,
): Promise<void> {
const rows = await listByPath(client, "/ip/ipsec/policy")
const managed = rows.find((r) => (r.comment ?? "").trim() === comment && (r.template === "true" || r.template === "yes"))
if (managed?.[".id"]) {
await client.patch(`/ip/ipsec/policy/${encodeURIComponent(managed[".id"])}`, toRosBody({
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
comment,
}))
return
}
await client.put("/ip/ipsec/policy", toRosBody({
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
template: "yes",
comment,
}))
}
export async function ensureIpsecPeer(
client: MikrotikClient,
name: string,
serverCertName: string,
profileName: string,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/peer", name, toRosBody({
name,
address: "0.0.0.0/0",
"exchange-mode": "ike2",
passive: "yes",
certificate: serverCertName,
"send-cert": "always",
profile: profileName,
comment,
}))
}
/** Managed srcnat masquerade: интернет клиентам VPN. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise<void> {
const rows = await listByPath(client, "/ip/firewall/nat")
const existing = rows.find(
(r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat",
)
if (existing?.[".id"]) return
await client.put("/ip/firewall/nat", toRosBody({
chain: "srcnat",
action: "masquerade",
"src-address": poolCidr,
"out-interface-list": "WAN",
comment,
}))
}
// ── клиенты ─────────────────────────────────────────────────────────────────
export async function putUserModeConfig(
client: MikrotikClient,
name: string,
staticIp: string,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
address: staticIp,
comment,
}))
}
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
const id = await findRosId(client, "/ip/ipsec/mode-config", name)
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`)
}
export interface IdentityFields {
peerName: string
modeConfig: string
comment: string
authMethod: "certificate" | "pre-shared-key"
/** cert: серверный сертификат (router представляет его клиенту). */
certificate?: string
/** cert: строгий мэтч конкретного клиента по его сертификату. */
remoteCertificate?: string
/** psk. */
secret?: string
remoteId?: string
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
placeBefore?: string
}
export function identityRosBody(f: IdentityFields): Record<string, string> {
return toRosBody({
peer: f.peerName,
"auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key",
certificate: f.certificate,
"remote-certificate": f.remoteCertificate,
"match-by": f.authMethod === "certificate" ? "certificate" : undefined,
secret: f.secret,
"remote-id": f.remoteId,
"mode-config": f.modeConfig,
"generate-policy": "port-strict",
"place-before": f.placeBefore,
comment: f.comment,
})
}
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
await client.put("/ip/ipsec/identity", identityRosBody(fields))
}
/**
* Общая listener-identity на IKEv2 peer для всех клиентов (reuse существующей, иначе создать).
* Без `remote-certificate`/`match-by` — RouterOS принимает любой клиентский серт, подписанный доверенным CA.
*/
export async function ensureSharedIke2Identity(
client: MikrotikClient,
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string },
): Promise<string | undefined> {
const identities = await listByPath(client, "/ip/ipsec/identity")
const peers = await listByPath(client, "/ip/ipsec/peer")
const peerNames = selectIke2Peers(peers as PeerLike[]).map((p) => (p.name ?? "").trim()).filter(Boolean)
const existing = selectSharedIke2Identity(
identities as Ike2IdentityLike[],
peerNames.length > 0 ? peerNames : [args.peerName],
)
if (existing?.[".id"]) return String(existing[".id"])
await putIdentity(client, {
peerName: args.peerName,
modeConfig: args.modeConfigName ?? "",
comment: args.comment,
authMethod: "certificate",
certificate: args.serverCertName,
})
return undefined
}
export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
}
export async function deleteIdentity(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
}
export async function patchPeer(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body)
}
export async function deletePeer(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`)
}
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
}
export { listByPath, findRosId, ipsecManagedComment }