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
Co-authored-by: Cursor <[email protected]>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
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")
|
||
}
|