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

304 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "node:crypto"
import { PRODUCT_NAME } from "../managed-markers.js"
import { isCaCertificate } from "./certificate-parse.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
}
export type IdentityLike = {
".id"?: string
comment?: string
"remote-id"?: string
"remote-certificate"?: string
peer?: string
}
/** Identity + поля, нужные для отбора IKEv2 remote-access. */
export interface Ike2IdentityLike extends IdentityLike {
certificate?: string
"auth-method"?: string
"mode-config"?: string
}
/** Peer (/ip/ipsec/peer). */
export interface PeerLike {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
comment?: string
}
export interface CertLike {
name?: string
"common-name"?: string
"key-usage"?: string
/** read-only: имя CA, которым подписан серт. */
ca?: string
/** флаги RouterOS (строка вида "KLAT"). */
flags?: string
authority?: string
}
/** Строка сертификата RouterOS (raw). */
type RosRow = Record<string, string | undefined>
/** Отображаемое имя identity: managed user= → сырой comment → remote-id → CN сертификата → peer#id. */
export function identityDisplayName(i: IdentityLike, certs: CertLike[] = []): string {
const comment = (i.comment ?? "").trim()
const managedName = parseIpsecUserComment(comment)
if (managedName) return managedName
if (comment) return comment
const remoteId = (i["remote-id"] ?? "").trim()
if (remoteId) return remoteId
const remoteCert = (i["remote-certificate"] ?? "").trim()
if (remoteCert) {
const cn = String(certs.find((c) => String(c.name ?? "").trim() === remoteCert)?.["common-name"] ?? "").trim()
return cn || remoteCert
}
const rosId = String(i[".id"] ?? "").replace(/^\*/, "")
const peer = (i.peer ?? "").trim()
if (peer) return `${peer}#${rosId.slice(0, 6) || "identity"}`
return rosId || "identity"
}
// ── 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"}`
}
// ── отбор IKEv2 remote-access (без GRE/site-to-site) ────────────────────────
function rosBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function isAnyAddress(a: string | undefined): boolean {
const v = (a ?? "").trim()
return v === "" || v === "0.0.0.0/0" || v === "::/0"
}
/** Peer с IKEv2 (exchange-mode ike2). */
export function isIke2Peer(p: PeerLike): boolean {
return (p["exchange-mode"] ?? "").trim().toLowerCase() === "ike2"
}
/**
* Remote-access IKEv2 peers. Строгий набор: ike2 + passive + any-address + сертификат.
* Fallback → ike2 + сертификат → любые ike2 (чтобы не потерять существующий сервер).
*/
export function selectIke2Peers<T extends PeerLike>(peers: T[]): T[] {
const ike2 = peers.filter(isIke2Peer)
const strict = ike2.filter(
(p) => rosBool(p.passive) && isAnyAddress(p.address) && Boolean((p.certificate ?? "").trim()),
)
if (strict.length > 0) return strict
const withCert = ike2.filter((p) => Boolean((p.certificate ?? "").trim()))
return withCert.length > 0 ? withCert : ike2
}
/**
* Identity участвует в IKEv2 remote-access: привязан к IKEv2 peer И имеет mode-config
* либо сертификат клиента. PSK-туннели без mode-config/сертификата отсеиваются.
* Пустой набор peer-имён → не ограничиваем по peer.
*/
export function isIke2RemoteAccessIdentity(
i: Ike2IdentityLike,
ike2PeerNames?: Iterable<string>,
): boolean {
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim())
const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key"
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
}
/**
* Общая listener-identity на IKEv2 peer: `remote-certificate` пуст, auth-method сертификатный.
* Именно она обслуживает всех клиентов, чей серт подписан доверенным CA (RouterOS валидирует по CA,
* см. docs «If a remote-certificate is not specified then the received certificate is checked against CA»).
* Приоритет managed, иначе первая подходящая (на устройстве это `denozord`/`ikev2-peer`).
*/
export function selectSharedIke2Identity<T extends Ike2IdentityLike>(
identities: T[],
ike2PeerNames?: Iterable<string>,
): T | undefined {
const names = new Set(Array.from(ike2PeerNames ?? [], (n) => n.trim()).filter(Boolean))
const candidates = identities.filter((i) => {
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
if ((i["remote-certificate"] ?? "").trim() !== "") return false
const auth = (i["auth-method"] ?? "").trim().toLowerCase()
if (auth !== "rsa-key" && auth !== "digital-signature") return false
return Boolean((i.certificate ?? "").trim())
})
return candidates.find((i) => isIpsecManagedComment(i.comment)) ?? candidates[0]
}
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
return certs.find((c) => names.has(String(c.name ?? "").trim()))
?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT)
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
}
/** CA для подписи новых клиентов: `ca` серверного серта (MyCA) → managed ipsec-ca → первый CA. */
export function resolveIke2CaName<T extends RosRow>(certs: T[], serverCert?: T | null): string | undefined {
const signed = String(serverCert?.ca ?? "").trim()
if (signed && certs.some((c) => String(c.name ?? "").trim() === signed)) return signed
if (certs.some((c) => String(c.name ?? "").trim() === IPSEC_CA_CERT)) return IPSEC_CA_CERT
const ca = certs.find((c) => isCaCertificate(c))
return ca ? String(ca.name ?? "").trim() || undefined : undefined
}
// ── пул адресов клиентов ────────────────────────────────────────────────────
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")
}