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
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:
@@ -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(""),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user