feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s

- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
Denozordec
2026-09-12 20:20:56 +07:00
parent 564aae21f0
commit 7755d77340
35 changed files with 4154 additions and 13 deletions
+2 -2
View File
@@ -96,7 +96,7 @@ export const filterRules = pgTable("filter_rules", {
export const configRevisions = pgTable("config_revisions", {
id: text("id").primaryKey(),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "gre"] }).notNull(),
section: text("section", { enum: ["filters", "recursive-routes", "firewall", "wireguard", "ipsec", "gre"] }).notNull(),
source: text("source", { enum: ["apply", "rollback", "observed", "copy"] }).notNull(),
fingerprint: text("fingerprint").notNull(),
payload: jsonb("payload").$type<unknown>().notNull().default(sql`'[]'::jsonb`),
@@ -716,7 +716,7 @@ export const userInterfaceBindings = pgTable("user_interface_bindings", {
userId: text("user_id").notNull().references(() => appUsers.id, { onDelete: "cascade" }),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "ipsec", "other"] })
.notNull().default("other"),
peerPublicKey: text("peer_public_key").notNull().default(""),
peerName: text("peer_name").notNull().default(""),
+2
View File
@@ -29,6 +29,7 @@ import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import ipsecRoutes from "./routes/ipsec.js"
import vxlanRoutes from "./routes/vxlan.js"
import containersRoutes from "./routes/containers.js"
import firewallRoutes from "./routes/firewall.js"
@@ -137,6 +138,7 @@ export async function buildApp(opts?: {
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(ipsecRoutes, { prefix: "/api" })
await app.register(vxlanRoutes, { prefix: "/api" })
await app.register(containersRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
+2
View File
@@ -155,6 +155,7 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/ipsec") ||
p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:read",
@@ -169,6 +170,7 @@ const RULES: Rule[] = [
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard") ||
p.startsWith("/api/ipsec") ||
p.startsWith("/api/firewall") ||
p.startsWith("/api/gre"),
permission: "mm:network:write",
+2 -1
View File
@@ -1,4 +1,4 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export type InterfaceType = "ether" | "gre" | "wg" | "ipsec" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
@@ -10,6 +10,7 @@ export function mapRosInterfaceType(raw: string | undefined | null, name?: strin
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ipsec")) return "ipsec"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
+7 -1
View File
@@ -28,7 +28,7 @@ export function peerDisplayName(opts: {
return truncPeerKey(opts.publicKey)
}
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
/** Ether/GRE — пустой ключ. WG — обязательный public-key. IPsec — CN сертификата клиента. */
export function normalizeBindingPeer(
type: InterfaceType,
peerPublicKey: string | undefined,
@@ -40,5 +40,11 @@ export function normalizeBindingPeer(
}
return key
}
if (type === "ipsec") {
if (!key) {
throw new PeerBindError("Для IPsec укажите клиента (CN сертификата)", 400)
}
return key
}
return ""
}
@@ -43,6 +43,7 @@ import {
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js"
export class UsersServiceError extends Error {
constructor(
@@ -305,7 +306,10 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
peersByIface.set(peer.interfaceName, list)
}
return ifaces.map((iface) => {
// IKEv2-клиенты — псевдо-интерфейс «ipsec-vpn» с пирами = клиенты (ключ = CN сертификата).
const ipsecLive = await listIpsecClientsForCatalog(serverId)
const entries: CatalogInterface[] = ifaces.map((iface) => {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
@@ -335,7 +339,37 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
}
}),
}
}).sort((a, b) => a.name.localeCompare(b.name))
})
if (ipsecLive.clients.length > 0) {
const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
entries.push({
name: "ipsec-vpn",
type: "ipsec",
running: true,
disabled: false,
boundUserId: ifaceBind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
peersError: ipsecLive.error,
peers: ipsecLive.clients.map((c) => {
const cn = c.commonName ?? c.name
const bind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && b.peerPublicKey === cn)
const peerOwner = bind ? usersById.get(bind.userId) : undefined
return {
publicKey: cn,
name: c.name,
comment: c.comment ?? "",
allowedIps: c.staticIp ? [c.staticIp] : [],
latestHandshake: c.activeSince,
boundUserId: bind?.userId ?? null,
boundUserLogin: peerOwner?.login ?? null,
}
}),
})
}
return entries.sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }
+498
View File
@@ -0,0 +1,498 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { FastifyReply } from "fastify"
import {
ipsecCertExportRequestSchema,
ipsecInitRequestSchema,
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,
findFreePoolIp,
ipsecManagedComment,
ipsecUserComment,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
userModeConfigName,
} from "../services/ipsec-config.js"
import {
ensureIpsecPeer,
ensureIpsecPool,
ensureIpsecProfile,
ensureIpsecProposal,
ensureNatRule,
ensurePolicyTemplate,
ensureSharedModeConfig,
putUserModeConfig,
deleteUserModeConfig,
putIdentity,
patchIdentity,
deleteIdentity,
listByPath,
} from "../services/ipsec-ros.js"
import {
ensureCaCertificate,
ensureServerCertificate,
exportClientP12,
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}` })
}
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; serverEndpoint: string; passphrase: string; dns?: string },
): Promise<IpsecCertBundle> {
const { fileName, content } = await exportClientP12(client, args.userName, args.passphrase)
const p12B64 = content.toString("base64")
const certName = clientCertName(args.userName)
return {
user: args.userName,
serverEndpoint: args.serverEndpoint,
filename: fileName,
contentB64: p12B64,
mime: "application/x-pkcs12",
passphrase: args.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: args.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 reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
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 peer = state.peers.find((p) => isIpsecManagedComment(p.comment) || (p.name ?? "").trim() === IPSEC_COMMON_NAME)
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
if (!peer || !sharedMc || !caCert || !serverCert) {
return reply.status(400).send({ error: "IKEv2-сервер не инициализирован — запустите мастер на вкладке «Сервер»" })
}
const peerName = (peer.name ?? "").trim()
const serverEndpoint = String(serverCert["common-name"] ?? "").trim()
const dns = sharedMc["static-dns"]?.trim() || undefined
if (body.authMethod === "pre-shared-key" && !body.psk) {
return reply.status(400).send({ error: "Для PSK-клиента укажите secret (psk)" })
}
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: IPSEC_CA_CERT,
daysValid: body.daysValid ?? 1825,
})
if (issued.existed) {
return reply.status(409).send({ error: `Клиент с сертификатом ${issued.certName} уже существует` })
}
certName = issued.certName
}
await putIdentity(client, {
peerName,
modeConfig,
comment: ipsecUserComment(body.name),
authMethod: body.authMethod,
certificate: body.authMethod === "certificate" ? IPSEC_SERVER_CERT : undefined,
remoteCertificate: certName,
secret: body.psk,
remoteId: body.authMethod === "pre-shared-key" ? (body.remoteId ?? body.name) : undefined,
})
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 identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
const oldName = parseIpsecUserComment(identity.comment) ?? identity.comment ?? ""
const sharedMc = state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
if (d.name && d.name !== oldName) {
await patchIdentity(client, identity[".id"]!, {
comment: ipsecUserComment(d.name),
})
}
if (d.staticIp !== undefined) {
if (d.staticIp == null) {
// вернуть выдачу из пула
if (sharedMc) await patchIdentity(client, identity[".id"]!, { "mode-config": (sharedMc.name ?? "").trim() })
const personal = (identity["mode-config"] ?? "").trim()
if (personal.startsWith("mc-ipsec-")) 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() !== (identity["mode-config"] ?? "").trim()),
)
if (others.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
const name = userModeConfigName(d.name || oldName)
await putUserModeConfig(client, name, ip, ipsecManagedComment(`клиент ${(d.name || oldName).trim()}`))
await patchIdentity(client, identity[".id"]!, { "mode-config": name })
}
}
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 identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
const userName = parseIpsecUserComment(identity.comment) ?? ""
const personal = (identity["mode-config"] ?? "").trim()
await deleteIdentity(client, identity[".id"]!)
if (personal.startsWith("mc-ipsec-")) await deleteUserModeConfig(client, personal)
if (removeCertificate && identity["remote-certificate"]) {
await client.removeCertificate(identity["remote-certificate"]).catch(() => undefined)
} else if (removeCertificate && userName) {
await client.removeCertificate(clientCertName(userName)).catch(() => undefined)
}
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 reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
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 } = 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: body.passphrase,
sswanFilename: `${certName}.sswan`,
sswanContent: buildSswanConfig({
name: `IKEv2 ${serverEndpoint}`,
serverEndpoint,
serverId: serverEndpoint,
p12B64,
}),
instructions: buildClientInstructions({
userName,
serverEndpoint,
p12Filename: fileName,
passphrase: body.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
+4 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { count } from "drizzle-orm"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { countIpsecClients } from "../services/ipsec-live.js"
import { countVxlanTunnels } from "../services/vxlan-live.js"
import { countContainers } from "../services/containers-live.js"
import { countBgpSessions } from "../services/bgp-peers-live.js"
@@ -27,9 +28,10 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
const uptimeProbesTotal = await tableCount(uptimeProbes)
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
const recursiveRoutesTotal = await tableCount(recursiveRoutes)
const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
const [certRes, wireguardTotal, ipsecTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
listCertificatesFromServers(),
countWireGuardInterfaces().catch(() => 0),
countIpsecClients().catch(() => 0),
countBgpSessions().catch(() => 0),
countVxlanTunnels().catch(() => 0),
countContainers().catch(() => 0),
@@ -46,6 +48,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
ipsec: ipsecTotal,
users: usersTotal,
bgpSessions: bgpTotal,
vxlan: vxlanTotal,
+1
View File
@@ -10,6 +10,7 @@ export const CONFIG_SECTIONS = [
"recursive-routes",
"firewall",
"wireguard",
"ipsec",
"gre",
] as const
@@ -2,13 +2,16 @@ import assert from "node:assert/strict"
import {
canonicalFirewallSnapshot,
canonicalGreSnapshot,
canonicalIpsecSnapshot,
canonicalWireguardSnapshot,
opsPaths,
opsTouchOnly,
parseIpsecSnapshot,
planFirewallRestore,
planGreCreate,
planGreDelete,
planGreRestore,
planIpsecRestore,
planWireguardRestore,
} from "./entity-snapshots.js"
import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
@@ -161,4 +164,53 @@ import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
assert.notEqual(p1, p3)
}
{
const desired = canonicalIpsecSnapshot({
peers: [{ name: "ipsec-vpn", address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, certificate: "ipsec-server", profile: "ipsec-vpn", comment: "MikrotikManager:ipsec", disabled: false }],
identities: [{ peerName: "ipsec-vpn", authMethod: "rsa-key", certificate: "ipsec-server", remoteCertificate: "ipsec-user-alice", matchBy: "certificate", secret: "", remoteId: "", modeConfig: "ipsec-vpn", generatePolicy: "port-strict", comment: "MikrotikManager:ipsec user=alice", disabled: false }],
modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn", address: "", staticDns: "10.77.0.1", comment: "MikrotikManager:ipsec" }],
pools: [{ name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", comment: "MikrotikManager:ipsec" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
})
// round-trip через payload ревизии
const restored = parseIpsecSnapshot(JSON.parse(JSON.stringify(desired)))
assert.equal(fingerprintPayload(restored), fingerprintPayload(desired))
assert.equal(restored.identities.length, 1)
assert.equal(restored.identities[0]?.comment, "MikrotikManager:ipsec user=alice")
const ops = planIpsecRestore(restored, {
peers: [
{ ...restored.peers[0]!, rosId: "*P1" },
{ name: "site-to-site", address: "203.0.113.7", exchangeMode: "ike2", passive: false, certificate: "", profile: "default", comment: "", disabled: false, rosId: "*P2" },
],
identities: [
{ ...restored.identities[0]!, secret: "(hidden)", rosId: "*I1" },
{
peerName: "site-to-site", authMethod: "pre-shared-key", certificate: "", remoteCertificate: "", matchBy: "",
secret: "(hidden)", remoteId: "peer-b", modeConfig: "", generatePolicy: "", comment: "не managed",
disabled: false, rosId: "*I2",
},
],
modeConfigs: [
{ ...restored.modeConfigs[0]!, rosId: "*M1" },
{ name: "mc-ipsec-ghost", addressPool: "", address: "10.77.0.9", staticDns: "", comment: "MikrotikManager:ipsec клиент ghost", rosId: "*M2" },
],
pools: [{ ...restored.pools[0]!, rosId: "*PL1" }],
nat: [{ ...restored.nat[0]!, rosId: "*N1" }],
})
// лишние managed-объекты удаляются, чужие (site-to-site / «не managed») не трогаем
assert.ok(!ops.some((op) => op.path.includes("*P2")), "чужой peer не тронут")
assert.ok(!ops.some((op) => op.path.includes("*I2")), "чужая identity не тронута")
assert.ok(ops.some((op) => op.op === "delete" && op.path === "/ip/ipsec/mode-config/*M2"), "персональный mc лишнего клиента удалён")
const patchOp = ops.find((op) => op.op === "patch" && op.path === "/ip/ipsec/identity/*I1")
assert.ok(patchOp, "identity желаемого клиента патчится")
assert.ok(patchOp?.body.secret === undefined, "секрет (hidden) не перезаписываем")
assert.equal(
opsTouchOnly(ops, ["/ip/ipsec", "/ip/pool", "/ip/firewall/nat"]),
true,
"restore не выходит за пределы ipsec-объектов",
)
}
console.log("entity-snapshots.test.ts: ok")
+312 -1
View File
@@ -1,4 +1,5 @@
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE. */
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */
import { isIpsecManagedComment } from "./ipsec-config.js"
export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
@@ -161,6 +162,94 @@ export interface GreLiveAddr {
address: string
}
// ── IPsec / IKEv2 (managed-объекты, маркер MikrotikManager:ipsec) ────────────
export interface IpsecSnapshotPeer {
name: string
address: string
exchangeMode: string
passive: boolean
certificate: string
profile: string
comment: string
disabled: boolean
}
export interface IpsecSnapshotIdentity {
peerName: string
authMethod: string
certificate: string
remoteCertificate: string
matchBy: string
secret: string
remoteId: string
modeConfig: string
generatePolicy: string
comment: string
disabled: boolean
}
export interface IpsecSnapshotModeConfig {
name: string
addressPool: string
address: string
staticDns: string
comment: string
}
export interface IpsecSnapshotPool {
name: string
ranges: string
comment: string
}
export interface IpsecSnapshotPolicy {
srcAddress: string
dstAddress: string
proposal: string
comment: string
}
export interface IpsecSnapshotNat {
chain: string
action: string
srcAddress: string
comment: string
}
export interface IpsecSnapshot {
peers: IpsecSnapshotPeer[]
identities: IpsecSnapshotIdentity[]
modeConfigs: IpsecSnapshotModeConfig[]
pools: IpsecSnapshotPool[]
policies: IpsecSnapshotPolicy[]
nat: IpsecSnapshotNat[]
}
export interface IpsecLivePeer extends IpsecSnapshotPeer {
rosId: string
}
export interface IpsecLiveIdentity extends IpsecSnapshotIdentity {
rosId: string
}
export interface IpsecLiveModeConfig extends IpsecSnapshotModeConfig {
rosId: string
}
export interface IpsecLivePool extends IpsecSnapshotPool {
rosId: string
}
export interface IpsecLivePolicy extends IpsecSnapshotPolicy {
rosId: string
}
export interface IpsecLiveNat extends IpsecSnapshotNat {
rosId: string
}
function str(v: unknown): string {
return String(v ?? "").trim()
}
@@ -508,6 +597,228 @@ export function planWireguardRestore(
return ops
}
// ── IPsec: canonical / parse / plan ─────────────────────────────────────────
function canonicalIpsecPeerRaw(p: Partial<IpsecSnapshotPeer>): IpsecSnapshotPeer {
return {
name: str(p.name),
address: str(p.address),
exchangeMode: str(p.exchangeMode),
passive: Boolean(p.passive),
certificate: str(p.certificate),
profile: str(p.profile),
comment: str(p.comment),
disabled: Boolean(p.disabled),
}
}
function canonicalIpsecIdentityRaw(i: Partial<IpsecSnapshotIdentity>): IpsecSnapshotIdentity {
return {
peerName: str(i.peerName),
authMethod: str(i.authMethod),
certificate: str(i.certificate),
remoteCertificate: str(i.remoteCertificate),
matchBy: str(i.matchBy),
secret: str(i.secret),
remoteId: str(i.remoteId),
modeConfig: str(i.modeConfig),
generatePolicy: str(i.generatePolicy),
comment: str(i.comment),
disabled: Boolean(i.disabled),
}
}
function canonicalIpsecModeConfigRaw(m: Partial<IpsecSnapshotModeConfig>): IpsecSnapshotModeConfig {
return {
name: str(m.name),
addressPool: str(m.addressPool),
address: str(m.address),
staticDns: str(m.staticDns),
comment: str(m.comment),
}
}
export function canonicalIpsecSnapshot(input: {
peers?: Array<Partial<IpsecSnapshotPeer>>
identities?: Array<Partial<IpsecSnapshotIdentity>>
modeConfigs?: Array<Partial<IpsecSnapshotModeConfig>>
pools?: Array<Partial<IpsecSnapshotPool>>
policies?: Array<Partial<IpsecSnapshotPolicy>>
nat?: Array<Partial<IpsecSnapshotNat>>
}): IpsecSnapshot {
return {
peers: (input.peers ?? []).map(canonicalIpsecPeerRaw).filter((p) => p.name).sort((a, b) => a.name.localeCompare(b.name)),
identities: (input.identities ?? [])
.map(canonicalIpsecIdentityRaw)
.filter((i) => i.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
modeConfigs: (input.modeConfigs ?? [])
.map(canonicalIpsecModeConfigRaw)
.filter((m) => m.name)
.sort((a, b) => a.name.localeCompare(b.name)),
pools: (input.pools ?? [])
.map((p) => ({ name: str(p.name), ranges: str(p.ranges), comment: str(p.comment) }))
.filter((p) => p.name)
.sort((a, b) => a.name.localeCompare(b.name)),
policies: (input.policies ?? [])
.map((p) => ({ srcAddress: str(p.srcAddress), dstAddress: str(p.dstAddress), proposal: str(p.proposal), comment: str(p.comment) }))
.filter((p) => p.dstAddress)
.sort((a, b) => a.dstAddress.localeCompare(b.dstAddress)),
nat: (input.nat ?? [])
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment) }))
.filter((n) => n.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
}
}
export function parseIpsecSnapshot(payload: unknown): IpsecSnapshot {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return canonicalIpsecSnapshot({})
}
const o = payload as Record<string, unknown>
return canonicalIpsecSnapshot({
peers: Array.isArray(o.peers) ? o.peers as Array<Partial<IpsecSnapshotPeer>> : [],
identities: Array.isArray(o.identities) ? o.identities as Array<Partial<IpsecSnapshotIdentity>> : [],
modeConfigs: Array.isArray(o.modeConfigs) ? o.modeConfigs as Array<Partial<IpsecSnapshotModeConfig>> : [],
pools: Array.isArray(o.pools) ? o.pools as Array<Partial<IpsecSnapshotPool>> : [],
policies: Array.isArray(o.policies) ? o.policies as Array<Partial<IpsecSnapshotPolicy>> : [],
nat: Array.isArray(o.nat) ? o.nat as Array<Partial<IpsecSnapshotNat>> : [],
})
}
function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
return compactBody({
name: p.name,
address: p.address,
"exchange-mode": p.exchangeMode,
passive: rosYesNo(p.passive),
certificate: p.certificate,
"send-cert": "always",
profile: p.profile,
comment: p.comment,
disabled: rosYesNo(p.disabled),
})
}
function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
return compactBody({
peer: i.peerName,
"auth-method": i.authMethod,
certificate: i.certificate,
"remote-certificate": i.remoteCertificate,
"match-by": i.matchBy,
secret: isHiddenSecret(i.secret) ? undefined : i.secret,
"remote-id": i.remoteId,
"mode-config": i.modeConfig,
"generate-policy": i.generatePolicy,
comment: i.comment,
disabled: rosYesNo(i.disabled),
})
}
function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string> {
return compactBody({
name: m.name,
"address-pool": m.addressPool,
address: m.address,
"static-dns": m.staticDns,
comment: m.comment,
})
}
function ipsecPoolBody(p: IpsecSnapshotPool): Record<string, string> {
return compactBody({ name: p.name, ranges: p.ranges, comment: p.comment })
}
function ipsecNatBody(n: IpsecSnapshotNat): Record<string, string> {
return compactBody({
chain: n.chain,
action: n.action,
"src-address": n.srcAddress,
comment: n.comment,
})
}
/** Restore только managed-объектов IKEv2 (peer/identity/mode-config/pool/nat; секреты (hidden) не перезаписываем;
* чужие (не ipsec-managed) live-объекты не трогаем даже если их передали). */
export function planIpsecRestore(
desiredInput: IpsecSnapshot,
current: {
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
},
): RosWriteOp[] {
const desired = canonicalIpsecSnapshot(desiredInput)
const ops: RosWriteOp[] = []
const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment))
const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.comment))
const managedModeConfigs = current.modeConfigs.filter((m) => isIpsecManagedComment(m.comment))
const managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment))
const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment))
for (const identity of managedIdentities) {
if (!desired.identities.some((i) => i.comment === identity.comment)) {
ops.push({ op: "delete", path: `/ip/ipsec/identity/${identity.rosId}` })
}
}
for (const mc of managedModeConfigs) {
if (!desired.modeConfigs.some((m) => m.name === mc.name)) {
ops.push({ op: "delete", path: `/ip/ipsec/mode-config/${mc.rosId}` })
}
}
for (const peer of managedPeers) {
if (!desired.peers.some((p) => p.name === peer.name)) {
ops.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` })
}
}
for (const pool of managedPools) {
if (!desired.pools.some((p) => p.name === pool.name)) {
ops.push({ op: "delete", path: `/ip/pool/${pool.rosId}` })
}
}
for (const rule of managedNat) {
if (!desired.nat.some((n) => n.comment === rule.comment)) {
ops.push({ op: "delete", path: `/ip/firewall/nat/${rule.rosId}` })
}
}
for (const want of desired.peers) {
const live = managedPeers.find((p) => p.name === want.name)
const body = ipsecPeerBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/peer", body })
else ops.push({ op: "patch", path: `/ip/ipsec/peer/${live.rosId}`, body })
}
for (const want of desired.modeConfigs) {
const live = managedModeConfigs.find((m) => m.name === want.name)
const body = ipsecModeConfigBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/mode-config", body })
else ops.push({ op: "patch", path: `/ip/ipsec/mode-config/${live.rosId}`, body })
}
for (const want of desired.pools) {
const live = managedPools.find((p) => p.name === want.name)
const body = ipsecPoolBody(want)
if (!live) ops.push({ op: "put", path: "/ip/pool", body })
else ops.push({ op: "patch", path: `/ip/pool/${live.rosId}`, body })
}
for (const want of desired.identities) {
const live = managedIdentities.find((i) => i.comment === want.comment)
const body = ipsecIdentityBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/identity", body })
else ops.push({ op: "patch", path: `/ip/ipsec/identity/${live.rosId}`, body })
}
for (const want of desired.nat) {
const live = managedNat.find((n) => n.comment === want.comment)
const body = ipsecNatBody(want)
if (!live) ops.push({ op: "put", path: "/ip/firewall/nat", body })
else ops.push({ op: "patch", path: `/ip/firewall/nat/${live.rosId}`, body })
}
return ops
}
export function canonicalGreSnapshot(input: {
tunnels?: Array<Partial<GreSnapshotTunnel>>
}): GreSnapshot {
+121
View File
@@ -0,0 +1,121 @@
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js"
type RosCertRow = Record<string, string | undefined>
const SIGN_POLL_TIMEOUT_MS = 90_000
const SIGN_POLL_INTERVAL_MS = 1_000
function isIp(value: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value.trim())
}
export async function findCertificate(client: MikrotikClient, name: string): Promise<RosCertRow | undefined> {
const certs = await client.getCertificates()
return certs.find((c) => String(c.name ?? "").trim() === name)
}
/** Сертификат подписан: у него заполнен invalid-after. */
export async function isCertificateSigned(client: MikrotikClient, name: string): Promise<boolean> {
const cert = await findCertificate(client, name)
return Boolean(cert && String(cert["invalid-after"] ?? "").trim() !== "")
}
/** sign в RouterOS не мгновенный: ждём появления invalid-after. */
export async function waitCertificateSigned(client: MikrotikClient, name: string, timeoutMs = SIGN_POLL_TIMEOUT_MS): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isCertificateSigned(client, name)) return
await new Promise((resolve) => setTimeout(resolve, SIGN_POLL_INTERVAL_MS))
}
throw new Error(`Сертификат ${name} не подписан за ${Math.round(timeoutMs / 1000)} с`)
}
/** Локальный CA для IKEv2 (self-signed); идемпотентно. Возвращает имя сертификата. */
export async function ensureCaCertificate(client: MikrotikClient, daysValid: number): Promise<string> {
const existing = await findCertificate(client, IPSEC_CA_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_CA_CERT,
"common-name": "MikrotikManager IPsec CA",
"key-size": "4096",
"key-usage": "key-cert-sign,crl-sign",
"days-valid": String(daysValid),
comment: ipsecManagedComment("CA"),
})
}
if (!(await isCertificateSigned(client, IPSEC_CA_CERT))) {
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
await waitCertificateSigned(client, IPSEC_CA_CERT)
}
return IPSEC_CA_CERT
}
/** Серверный сертификат (CN/SAN = адрес, по которому стучатся клиенты); подписывается CA. */
export async function ensureServerCertificate(
client: MikrotikClient,
args: { serverEndpoint: string; caCertName: string; daysValid: number },
): Promise<string> {
const endpoint = args.serverEndpoint.trim()
const san = isIp(endpoint) ? `IP:${endpoint}` : `DNS:${endpoint}`
const existing = await findCertificate(client, IPSEC_SERVER_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_SERVER_CERT,
"common-name": endpoint,
"subject-alt-name": san,
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-server",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment("server"),
})
}
if (!(await isCertificateSigned(client, IPSEC_SERVER_CERT))) {
await client.signCertificate({ name: IPSEC_SERVER_CERT, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, IPSEC_SERVER_CERT)
}
return IPSEC_SERVER_CERT
}
export interface IssuedClientCert {
certName: string
commonName: string
/** Сертификат с этим CN уже существовал (перевыпуск не выполнялся). */
existed: boolean
}
/** Клиентский сертификат: add + sign CA. CN = имя пользователя. */
export async function issueClientCertificate(
client: MikrotikClient,
args: { userName: string; caCertName: string; daysValid: number },
): Promise<IssuedClientCert> {
const certName = clientCertName(args.userName)
const existing = await findCertificate(client, certName)
if (existing) {
return { certName, commonName: String(existing["common-name"] ?? args.userName), existed: true }
}
await client.addCertificate({
name: certName,
"common-name": args.userName.trim(),
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-client",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment(`client ${args.userName.trim()}`),
})
await client.signCertificate({ name: certName, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, certName)
return { certName, commonName: args.userName.trim(), existed: false }
}
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
export async function exportClientP12(
client: MikrotikClient,
userName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
const certName = clientCertName(userName)
const cert = await findCertificate(client, certName)
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
return { fileName, content, certName }
}
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict"
import {
IPSEC_MARKER,
buildClientInstructions,
buildSswanConfig,
clientCertName,
findFreePoolIp,
ipsecManagedComment,
ipsecSlug,
ipsecUserComment,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
userModeConfigName,
} from "./ipsec-config.js"
{
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
assert.equal(ipsecSlug("Иван"), "")
assert.equal(clientCertName("Alice Cooper"), "ipsec-user-alice-cooper")
assert.equal(userModeConfigName("Bob"), "mc-ipsec-bob")
}
{
assert.ok(isIpsecManagedComment(ipsecManagedComment("IKEv2 road-warrior")))
assert.ok(isIpsecManagedComment(`${IPSEC_MARKER} user=alice`))
assert.ok(!isIpsecManagedComment("какой-то чужой комментарий"))
assert.ok(!isIpsecManagedComment(undefined))
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice")
assert.equal(parseIpsecUserComment("не managed"), null)
assert.equal(parseIpsecUserComment(null), null)
}
{
assert.equal(poolRangesFromCidr("10.77.0.0/24"), "10.77.0.2-10.77.0.254")
assert.equal(poolRangesFromCidr("10.77.0.1/24"), "10.77.0.2-10.77.0.254")
assert.equal(poolRangesFromCidr("192.168.7.0/28"), "192.168.7.2-192.168.7.14")
assert.equal(poolRangesFromCidr("10.77.0.0/31"), null, "/31 — нет адресов клиентам")
assert.equal(poolRangesFromCidr("10.77.0.0"), null)
assert.equal(poolRangesFromCidr("не-сидр"), null)
}
{
const range = "10.77.0.2-10.77.0.10"
assert.equal(findFreePoolIp(range, []), "10.77.0.2")
assert.equal(findFreePoolIp(range, ["10.77.0.2"]), "10.77.0.3")
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3/32", "10.77.0.4"]), "10.77.0.5")
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3", "10.77.0.4", "10.77.0.5", "10.77.0.6", "10.77.0.7", "10.77.0.8", "10.77.0.9", "10.77.0.10"]), null)
// одиночный адрес без диапазона
assert.equal(findFreePoolIp("10.77.0.7", []), "10.77.0.7")
assert.equal(findFreePoolIp("мусор", []), null)
}
{
const sswan = buildSswanConfig({
name: "IKEv2 vpn.example.com",
serverEndpoint: "vpn.example.com",
serverId: "vpn.example.com",
p12B64: "cDEy",
uuid: "fix-me",
})
const parsed = JSON.parse(sswan) as Record<string, unknown>
assert.equal(parsed.version, 1)
assert.equal(parsed.type, "ikev2-cert")
const remote = parsed.remote as Record<string, string>
const local = parsed.local as Record<string, string>
assert.equal(remote.addr, "vpn.example.com")
assert.equal(remote.id, "vpn.example.com")
assert.equal(local.p12, "cDEy")
}
{
const text = buildClientInstructions({
userName: "alice",
serverEndpoint: "vpn.example.com",
p12Filename: "cert_export_ipsec-user-alice.p12",
passphrase: "s3cret",
dns: "10.77.0.1",
})
assert.ok(text.includes("alice"))
assert.ok(text.includes("vpn.example.com"))
assert.ok(text.includes("s3cret"))
assert.ok(text.includes("strongSwan"))
}
console.log("ipsec-config.test.ts: ok")
+158
View File
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto"
import { PRODUCT_NAME } from "../managed-markers.js"
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
export const IPSEC_MARKER = `${PRODUCT_NAME}:ipsec`
export function ipsecManagedComment(label = ""): string {
return label ? `${IPSEC_MARKER} ${label}` : IPSEC_MARKER
}
export function isIpsecManagedComment(comment: string | undefined | null): boolean {
return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER)
}
/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
export function ipsecUserComment(name: string): string {
return `${IPSEC_MARKER} user=${name.trim()}`
}
export function parseIpsecUserComment(comment: string | undefined | null): string | null {
if (!comment) return null
const m = /user=([^\s]+)/.exec(comment.trim())
return m?.[1] ?? null
}
// ── naming-конвенции управляемых объектов RouterOS ──────────────────────────
export const IPSEC_CA_CERT = "ipsec-ca"
export const IPSEC_SERVER_CERT = "ipsec-server"
/** Общее имя peer / profile / proposal / пула / общего mode-config. */
export const IPSEC_COMMON_NAME = "ipsec-vpn"
export function ipsecSlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
export function clientCertName(userName: string): string {
return `ipsec-user-${ipsecSlug(userName) || "client"}`
}
export function userModeConfigName(userName: string): string {
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
}
// ── пул адресов клиентов ────────────────────────────────────────────────────
function ipToInt(b: Array<number | undefined>): number {
return (((b[0]! << 24) | (b[1]! << 16) | (b[2]! << 8) | b[3]!) >>> 0)
}
function intToIp(n: number): string {
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")
}
function parseIpv4(s: string): number | null {
const parts = s.trim().split(".")
if (parts.length !== 4) return null
const octets = parts.map((p) => Number(p))
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null
return ipToInt(octets)
}
/** «10.77.0.0/24» → pool ranges «10.77.0.2-10.77.0.254» (шлюзы .1 и broadcast не раздаём). */
export function poolRangesFromCidr(cidr: string): string | null {
const m = /^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/.exec(cidr.trim())
if (!m) return null
const base = parseIpv4(m[1]!)
const prefix = Number(m[2])
if (base == null || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return null
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
const network = (base & mask) >>> 0
const size = 2 ** (32 - prefix)
const from = network + 2
const to = network + size - 2
if (to <= from) return null
return `${intToIp(from)}-${intToIp(to)}`
}
export interface PoolIp {
ip: string
n: number
}
/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e», берём первый диапазон из списка), исключая занятые. */
export function findFreePoolIp(range: string, taken: Iterable<string>): string | null {
const takenSet = new Set(
Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()),
)
const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? ""
const [fromRaw, toRaw] = first.split("-")
const from = parseIpv4(fromRaw ?? "")
const to = parseIpv4(toRaw ?? fromRaw ?? "")
if (from == null) return null
const last = to ?? from
if (last < from) return null
const cap = Math.min(last, from + 65_534)
for (let n = from; n <= cap; n++) {
const ip = intToIp(n)
if (!takenSet.has(ip)) return ip
}
return null
}
// ── клиентские конфиги ──────────────────────────────────────────────────────
/** strongSwan (Android/iOS) .sswan-профиль с встроенным .p12. */
export function buildSswanConfig(args: {
name: string
serverEndpoint: string
serverId: string
p12B64: string
uuid?: string
}): string {
return JSON.stringify(
{
version: 1,
uuid: args.uuid ?? randomUUID(),
name: args.name,
type: "ikev2-cert",
remote: { addr: args.serverEndpoint, id: args.serverId },
local: { p12: args.p12B64 },
"ike-proposal": "AES256-SHA256-MODP2048",
"esp-proposal": "AES256-SHA256-MODP2048",
},
null,
2,
)
}
/** Короткая текстовая инструкция по подключению (Windows/macOS/iOS/Android). */
export function buildClientInstructions(args: {
userName: string
serverEndpoint: string
p12Filename: string
passphrase?: string
dns?: string
}): string {
const pass = args.passphrase ? `Пароль архива (при импорте): ${args.passphrase}\n` : ""
return [
`IKEv2/IPsec VPN — клиент «${args.userName}»`,
`Сервер: ${args.serverEndpoint}${args.dns ? ` (DNS: ${args.dns})` : ""}`,
"",
`1. Скачайте и импортируйте сертификат ${args.p12Filename}.`,
pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.",
"2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»",
" (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).",
"3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),",
" аутентификация — сертификат; удалённый ID = CN серверного сертификата.",
"4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).",
"",
"Адрес выдаётся автоматически при подключении (mode-config).",
].join("\n")
}
+513
View File
@@ -0,0 +1,513 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type {
IpsecCertInfoDto,
IpsecClientDto,
IpsecListResponse,
IpsecModeConfigDto,
IpsecPeerDto,
IpsecPoolDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
clientCertName,
isIpsecManagedComment,
parseIpsecUserComment,
} from "./ipsec-config.js"
import {
canonicalIpsecSnapshot,
type IpsecLiveIdentity,
type IpsecLiveModeConfig,
type IpsecLiveNat,
type IpsecLivePeer,
type IpsecLivePool,
type IpsecSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect
export interface RosIpsecPeer {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
profile?: string
disabled?: string
comment?: string
}
export interface RosIpsecIdentity {
".id"?: string
peer?: string
"auth-method"?: string
certificate?: string
"remote-certificate"?: string
"match-by"?: string
secret?: string
"remote-id"?: string
"mode-config"?: string
"generate-policy"?: string
disabled?: string
comment?: string
}
export interface RosIpsecModeConfig {
".id"?: string
name?: string
"address-pool"?: string
"address-prefix"?: string
address?: string
"split-dns"?: string
"static-dns"?: string
comment?: string
}
export interface RosIpsecPool {
".id"?: string
name?: string
ranges?: string
comment?: string
}
export interface RosIpsecPolicy {
".id"?: string
"src-address"?: string
"dst-address"?: string
proposal?: string
template?: string
comment?: string
}
export interface RosFirewallNat {
".id"?: string
chain?: string
action?: string
"src-address"?: string
comment?: string
}
export interface RosIpsecActivePeer {
".id"?: string
address?: string
"remote-id"?: string
identity?: string
established?: string
}
type RosCertRow = Record<string, string | undefined>
function asBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
const name = String(c.name ?? "")
const keyUsage = String(c["key-usage"] ?? "")
const isCa = keyUsage.includes("key-cert-sign")
const isUser = name.startsWith("ipsec-user-")
return {
name,
commonName: c["common-name"] || undefined,
keySize: c["key-size"] || undefined,
fingerprint: c.fingerprint || undefined,
expiresAt: c["invalid-after"] || undefined,
trusted: asBool(c.trusted),
hasPrivateKey: asBool(c["private-key"]),
role: isCa ? "ca" : name === IPSEC_SERVER_CERT ? "server" : isUser ? "client" : "other",
managed: isCa && name === IPSEC_CA_CERT
|| name === IPSEC_SERVER_CERT
|| isUser
|| isIpsecManagedComment(c.comment),
}
}
function mapPeer(server: ServerRow, p: RosIpsecPeer): IpsecPeerDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "peer")}`,
rosId: String(p[".id"] ?? p.name ?? "peer"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name: (p.name ?? "").trim(),
address: p.address || undefined,
exchangeMode: p["exchange-mode"] || undefined,
passive: asBool(p.passive),
certificate: p.certificate || undefined,
profile: p.profile || undefined,
disabled: asBool(p.disabled),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfigDto {
return {
id: `${server.id}:${String(m[".id"] ?? m.name ?? "mc")}`,
rosId: String(m[".id"] ?? m.name ?? "mc"),
serverId: String(server.id),
name: (m.name ?? "").trim(),
addressPool: m["address-pool"] || m["address-prefix"] || undefined,
address: m.address || undefined,
splitDns: m["split-dns"] || undefined,
staticDns: m["static-dns"] || undefined,
comment: m.comment || undefined,
managed: isIpsecManagedComment(m.comment),
}
}
function mapPool(server: ServerRow, p: RosIpsecPool): IpsecPoolDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "pool")}`,
rosId: String(p[".id"] ?? p.name ?? "pool"),
serverId: String(server.id),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
export interface IpsecServerState {
server: ServerRow
client: MikrotikClient
peers: RosIpsecPeer[]
identities: RosIpsecIdentity[]
modeConfigs: RosIpsecModeConfig[]
pools: RosIpsecPool[]
policies: RosIpsecPolicy[]
nat: RosFirewallNat[]
active: RosIpsecActivePeer[]
certs: RosCertRow[]
}
export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerState> {
const client = MikrotikClient.fromServer(server)
const empty = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
client.get<unknown>("/ip/ipsec/peer").then((v) => empty<RosIpsecPeer>(v)).catch(() => [] as RosIpsecPeer[]),
client.get<unknown>("/ip/ipsec/identity").then((v) => empty<RosIpsecIdentity>(v)).catch(() => [] as RosIpsecIdentity[]),
client.get<unknown>("/ip/ipsec/mode-config").then((v) => empty<RosIpsecModeConfig>(v)).catch(() => [] as RosIpsecModeConfig[]),
client.get<unknown>("/ip/pool").then((v) => empty<RosIpsecPool>(v)).catch(() => [] as RosIpsecPool[]),
client.get<unknown>("/ip/ipsec/policy").then((v) => empty<RosIpsecPolicy>(v)).catch(() => [] as RosIpsecPolicy[]),
client.get<unknown>("/ip/firewall/nat").then((v) => empty<RosFirewallNat>(v)).catch(() => [] as RosFirewallNat[]),
client.get<unknown>("/ip/ipsec/active-peers").then((v) => empty<RosIpsecActivePeer>(v)).catch(() => [] as RosIpsecActivePeer[]),
client.getCertificates().catch(() => [] as RosCertRow[]),
])
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/** Клиенты = managed-идентичности (созданы менеджером). */
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const certByCommonName = new Map<string, RosCertRow>()
for (const c of state.certs) {
const cn = String(c["common-name"] ?? "").trim()
if (cn) certByCommonName.set(cn, c)
}
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
const activeByRemote = new Map<string, RosIpsecActivePeer>()
for (const a of state.active) {
const rid = String(a["remote-id"] ?? "").trim()
if (rid) activeByRemote.set(rid, a)
}
return state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i): IpsecClientDto => {
const comment = i.comment ?? ""
const name = parseIpsecUserComment(comment) ?? (comment || "client")
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
const certName = (i["remote-certificate"] ?? "").trim()
const cn = certName
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "")
: ""
const mcName = (i["mode-config"] ?? "").trim()
const mc = mcByName.get(mcName)
const staticIp = mc && (mc.name ?? "").startsWith("mc-ipsec-")
? (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "") || undefined
: undefined
const active = (cn ? activeByRemote.get(cn) : undefined)
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
return {
id: `${server.id}:${String(i[".id"] ?? "identity")}`,
rosId: String(i[".id"] ?? "identity"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name,
authMethod: psk ? "pre-shared-key" : "certificate",
certificateName: certName || undefined,
commonName: cn || undefined,
remoteId: (i["remote-id"] ?? "").trim() || undefined,
staticIp,
modeConfigName: mcName || undefined,
peerName: (i.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(i.disabled),
comment: comment || undefined,
managed: true,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
const server = state.server
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
const sharedMc = state.modeConfigs.find(
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
)
const pool = sharedMc
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
return {
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
initialized: Boolean(managedPeer && caCert && serverCert),
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
peer: managedPeer ? mapPeer(server, managedPeer) : undefined,
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert: caCert ? mapCertificate(caCert) : undefined,
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: state.certs
.filter((c) => {
const n = String(c.name ?? "")
return n === IPSEC_CA_CERT || n === IPSEC_SERVER_CERT || n.startsWith("ipsec-user-")
})
.map(mapCertificate),
}
}
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
const state = await fetchIpsecState(server)
return canonicalIpsecSnapshot({
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "").trim(),
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
policies: state.policies
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
srcAddress: (p["src-address"] ?? "").trim(),
dstAddress: (p["dst-address"] ?? "").trim(),
proposal: (p.proposal ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
})
}
export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
client: MikrotikClient
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
snapshot: IpsecSnapshot
}> {
const state = await fetchIpsecState(server)
return {
client: state.client,
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
rosId: String(i[".id"] ?? ""),
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "") === "pre-shared-key" ? "pre-shared-key" as const : "rsa-key" as const,
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
rosId: String(m[".id"] ?? ""),
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
rosId: String(n[".id"] ?? ""),
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
snapshot: await captureIpsecSnapshot(server),
}
}
export type IpsecListResult = IpsecListResponse
export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecListResult> {
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: IpsecListResult["failures"] = []
const summaries: IpsecServerSummaryDto[] = []
const clients: IpsecClientDto[] = []
await Promise.all(
serverRows.map(async (server) => {
try {
const state = await fetchIpsecState(server)
const serverClients = mapClients(state)
summaries.push(mapServerSummary(state, serverClients))
clients.push(...serverClients)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
}
}),
)
summaries.sort((a, b) => a.serverName.localeCompare(b.serverName))
clients.sort((a, b) => a.name.localeCompare(b.name))
return { servers: summaries, clients, failures }
}
export async function countIpsecClients(): Promise<number> {
try {
const result = await Promise.race([
listIpsec(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.clients.length
} catch {
return 0
}
}
export async function getEnabledIpsecServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
export async function listIpsecClientsForCatalog(serverId: number): Promise<{
clients: IpsecClientDto[]
error?: string
}> {
const row = await getEnabledIpsecServerById(serverId)
if (!row) return { clients: [], error: "Сервер не найден" }
try {
const state = await Promise.race([
fetchIpsecState(row),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут RouterOS")), 5_000)
}),
])
return { clients: mapClients(state) }
} catch (e) {
return { clients: [], error: e instanceof Error ? e.message : String(e) }
}
}
export { clientCertName }
+214
View File
@@ -0,0 +1,214 @@
import type { MikrotikClient } from "./mikrotik.js"
import { ipsecManagedComment } 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
}
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",
comment: f.comment,
})
}
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
await client.put("/ip/ipsec/identity", identityRosBody(fields))
}
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)}`)
}
/** Снять с 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 }
+137
View File
@@ -267,6 +267,57 @@ function rosDelete(
})
}
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */
function rosDownload(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "GET",
headers: { Authorization: authHeader },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const req = lib.request(options, (res) => {
const chunks: Buffer[] = []
res.on("data", (chunk: Buffer) => { chunks.push(chunk) })
res.on("end", () => {
const buf = Buffer.concat(chunks)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200)))
return
}
const contentType = String(res.headers["content-type"] ?? "")
if (contentType.includes("application/json")) {
reject(new Error(`RouterOS вернул метаданные вместо содержимого файла ${path}`))
return
}
resolve(buf)
})
})
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
req.on("close", () => clearTimeout(timer))
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
function rosPatch(
params: MikrotikConnectParams,
path: string,
@@ -621,6 +672,92 @@ export class MikrotikClient {
}
}
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */
async downloadFile(fileName: string, timeoutMs = 30_000): Promise<Buffer> {
const normalized = routerFileBasename(fileName)
const candidates = [normalized, `flash/${normalized}`]
let lastError: unknown
for (const name of candidates) {
try {
return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
}
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
return this.post("/certificate/add", body, timeoutMs)
}
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
async signCertificate(params: {
name: string
ca?: string
daysValid?: number
}, timeoutMs = 120_000): Promise<unknown> {
const body: Record<string, string> = { name: params.name }
if (params.ca) body.ca = params.ca
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
try {
return await this.post("/certificate/sign", body, timeoutMs)
} catch (e) {
// Некоторые версии REST принимают цель подписи только как .id.
const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === params.name)
const id = row?.[".id"]
if (!id) throw e
return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs)
}
}
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */
async exportCertificate(params: {
name: string
type: "pkcs12" | "pem"
passphrase?: string
}, timeoutMs = 60_000): Promise<string> {
const body: Record<string, string> = { name: params.name, type: params.type }
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
let raw: unknown
try {
raw = await this.post("/certificate/export-certificate", body, timeoutMs)
} catch (e) {
const certs = await this.getCertificates()
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
if (!id) throw e
raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
}
void raw
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
const ext = params.type === "pkcs12" ? "p12" : "crt"
const wanted = [`${params.name}.${ext}`, `cert_export_${params.name}.${ext}`]
const files = await this.listFiles()
const hit = files.find((f) => wanted.includes(f.name))
?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name))
if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`)
return hit.name
}
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> {
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase })
const content = await this.downloadFile(fileName)
return { fileName, content }
}
async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === nameOrId || c[".id"] === nameOrId)
const id = row?.[".id"]
if (!id) return
await this.delete(`/certificate/${encodeURIComponent(id)}`, timeoutMs)
}
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
try {