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

- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
Denozordec
2026-09-12 20:20:56 +07:00
parent 564aae21f0
commit 7755d77340
35 changed files with 4154 additions and 13 deletions
+1
View File
@@ -10,6 +10,7 @@ export const CONFIG_SECTIONS = [
"recursive-routes",
"firewall",
"wireguard",
"ipsec",
"gre",
] as const
@@ -2,13 +2,16 @@ import assert from "node:assert/strict"
import {
canonicalFirewallSnapshot,
canonicalGreSnapshot,
canonicalIpsecSnapshot,
canonicalWireguardSnapshot,
opsPaths,
opsTouchOnly,
parseIpsecSnapshot,
planFirewallRestore,
planGreCreate,
planGreDelete,
planGreRestore,
planIpsecRestore,
planWireguardRestore,
} from "./entity-snapshots.js"
import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
@@ -161,4 +164,53 @@ import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
assert.notEqual(p1, p3)
}
{
const desired = canonicalIpsecSnapshot({
peers: [{ name: "ipsec-vpn", address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, certificate: "ipsec-server", profile: "ipsec-vpn", comment: "MikrotikManager:ipsec", disabled: false }],
identities: [{ peerName: "ipsec-vpn", authMethod: "rsa-key", certificate: "ipsec-server", remoteCertificate: "ipsec-user-alice", matchBy: "certificate", secret: "", remoteId: "", modeConfig: "ipsec-vpn", generatePolicy: "port-strict", comment: "MikrotikManager:ipsec user=alice", disabled: false }],
modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn", address: "", staticDns: "10.77.0.1", comment: "MikrotikManager:ipsec" }],
pools: [{ name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", comment: "MikrotikManager:ipsec" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
})
// round-trip через payload ревизии
const restored = parseIpsecSnapshot(JSON.parse(JSON.stringify(desired)))
assert.equal(fingerprintPayload(restored), fingerprintPayload(desired))
assert.equal(restored.identities.length, 1)
assert.equal(restored.identities[0]?.comment, "MikrotikManager:ipsec user=alice")
const ops = planIpsecRestore(restored, {
peers: [
{ ...restored.peers[0]!, rosId: "*P1" },
{ name: "site-to-site", address: "203.0.113.7", exchangeMode: "ike2", passive: false, certificate: "", profile: "default", comment: "", disabled: false, rosId: "*P2" },
],
identities: [
{ ...restored.identities[0]!, secret: "(hidden)", rosId: "*I1" },
{
peerName: "site-to-site", authMethod: "pre-shared-key", certificate: "", remoteCertificate: "", matchBy: "",
secret: "(hidden)", remoteId: "peer-b", modeConfig: "", generatePolicy: "", comment: "не managed",
disabled: false, rosId: "*I2",
},
],
modeConfigs: [
{ ...restored.modeConfigs[0]!, rosId: "*M1" },
{ name: "mc-ipsec-ghost", addressPool: "", address: "10.77.0.9", staticDns: "", comment: "MikrotikManager:ipsec клиент ghost", rosId: "*M2" },
],
pools: [{ ...restored.pools[0]!, rosId: "*PL1" }],
nat: [{ ...restored.nat[0]!, rosId: "*N1" }],
})
// лишние managed-объекты удаляются, чужие (site-to-site / «не managed») не трогаем
assert.ok(!ops.some((op) => op.path.includes("*P2")), "чужой peer не тронут")
assert.ok(!ops.some((op) => op.path.includes("*I2")), "чужая identity не тронута")
assert.ok(ops.some((op) => op.op === "delete" && op.path === "/ip/ipsec/mode-config/*M2"), "персональный mc лишнего клиента удалён")
const patchOp = ops.find((op) => op.op === "patch" && op.path === "/ip/ipsec/identity/*I1")
assert.ok(patchOp, "identity желаемого клиента патчится")
assert.ok(patchOp?.body.secret === undefined, "секрет (hidden) не перезаписываем")
assert.equal(
opsTouchOnly(ops, ["/ip/ipsec", "/ip/pool", "/ip/firewall/nat"]),
true,
"restore не выходит за пределы ipsec-объектов",
)
}
console.log("entity-snapshots.test.ts: ok")
+312 -1
View File
@@ -1,4 +1,5 @@
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE. */
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */
import { isIpsecManagedComment } from "./ipsec-config.js"
export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
@@ -161,6 +162,94 @@ export interface GreLiveAddr {
address: string
}
// ── IPsec / IKEv2 (managed-объекты, маркер MikrotikManager:ipsec) ────────────
export interface IpsecSnapshotPeer {
name: string
address: string
exchangeMode: string
passive: boolean
certificate: string
profile: string
comment: string
disabled: boolean
}
export interface IpsecSnapshotIdentity {
peerName: string
authMethod: string
certificate: string
remoteCertificate: string
matchBy: string
secret: string
remoteId: string
modeConfig: string
generatePolicy: string
comment: string
disabled: boolean
}
export interface IpsecSnapshotModeConfig {
name: string
addressPool: string
address: string
staticDns: string
comment: string
}
export interface IpsecSnapshotPool {
name: string
ranges: string
comment: string
}
export interface IpsecSnapshotPolicy {
srcAddress: string
dstAddress: string
proposal: string
comment: string
}
export interface IpsecSnapshotNat {
chain: string
action: string
srcAddress: string
comment: string
}
export interface IpsecSnapshot {
peers: IpsecSnapshotPeer[]
identities: IpsecSnapshotIdentity[]
modeConfigs: IpsecSnapshotModeConfig[]
pools: IpsecSnapshotPool[]
policies: IpsecSnapshotPolicy[]
nat: IpsecSnapshotNat[]
}
export interface IpsecLivePeer extends IpsecSnapshotPeer {
rosId: string
}
export interface IpsecLiveIdentity extends IpsecSnapshotIdentity {
rosId: string
}
export interface IpsecLiveModeConfig extends IpsecSnapshotModeConfig {
rosId: string
}
export interface IpsecLivePool extends IpsecSnapshotPool {
rosId: string
}
export interface IpsecLivePolicy extends IpsecSnapshotPolicy {
rosId: string
}
export interface IpsecLiveNat extends IpsecSnapshotNat {
rosId: string
}
function str(v: unknown): string {
return String(v ?? "").trim()
}
@@ -508,6 +597,228 @@ export function planWireguardRestore(
return ops
}
// ── IPsec: canonical / parse / plan ─────────────────────────────────────────
function canonicalIpsecPeerRaw(p: Partial<IpsecSnapshotPeer>): IpsecSnapshotPeer {
return {
name: str(p.name),
address: str(p.address),
exchangeMode: str(p.exchangeMode),
passive: Boolean(p.passive),
certificate: str(p.certificate),
profile: str(p.profile),
comment: str(p.comment),
disabled: Boolean(p.disabled),
}
}
function canonicalIpsecIdentityRaw(i: Partial<IpsecSnapshotIdentity>): IpsecSnapshotIdentity {
return {
peerName: str(i.peerName),
authMethod: str(i.authMethod),
certificate: str(i.certificate),
remoteCertificate: str(i.remoteCertificate),
matchBy: str(i.matchBy),
secret: str(i.secret),
remoteId: str(i.remoteId),
modeConfig: str(i.modeConfig),
generatePolicy: str(i.generatePolicy),
comment: str(i.comment),
disabled: Boolean(i.disabled),
}
}
function canonicalIpsecModeConfigRaw(m: Partial<IpsecSnapshotModeConfig>): IpsecSnapshotModeConfig {
return {
name: str(m.name),
addressPool: str(m.addressPool),
address: str(m.address),
staticDns: str(m.staticDns),
comment: str(m.comment),
}
}
export function canonicalIpsecSnapshot(input: {
peers?: Array<Partial<IpsecSnapshotPeer>>
identities?: Array<Partial<IpsecSnapshotIdentity>>
modeConfigs?: Array<Partial<IpsecSnapshotModeConfig>>
pools?: Array<Partial<IpsecSnapshotPool>>
policies?: Array<Partial<IpsecSnapshotPolicy>>
nat?: Array<Partial<IpsecSnapshotNat>>
}): IpsecSnapshot {
return {
peers: (input.peers ?? []).map(canonicalIpsecPeerRaw).filter((p) => p.name).sort((a, b) => a.name.localeCompare(b.name)),
identities: (input.identities ?? [])
.map(canonicalIpsecIdentityRaw)
.filter((i) => i.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
modeConfigs: (input.modeConfigs ?? [])
.map(canonicalIpsecModeConfigRaw)
.filter((m) => m.name)
.sort((a, b) => a.name.localeCompare(b.name)),
pools: (input.pools ?? [])
.map((p) => ({ name: str(p.name), ranges: str(p.ranges), comment: str(p.comment) }))
.filter((p) => p.name)
.sort((a, b) => a.name.localeCompare(b.name)),
policies: (input.policies ?? [])
.map((p) => ({ srcAddress: str(p.srcAddress), dstAddress: str(p.dstAddress), proposal: str(p.proposal), comment: str(p.comment) }))
.filter((p) => p.dstAddress)
.sort((a, b) => a.dstAddress.localeCompare(b.dstAddress)),
nat: (input.nat ?? [])
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment) }))
.filter((n) => n.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
}
}
export function parseIpsecSnapshot(payload: unknown): IpsecSnapshot {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return canonicalIpsecSnapshot({})
}
const o = payload as Record<string, unknown>
return canonicalIpsecSnapshot({
peers: Array.isArray(o.peers) ? o.peers as Array<Partial<IpsecSnapshotPeer>> : [],
identities: Array.isArray(o.identities) ? o.identities as Array<Partial<IpsecSnapshotIdentity>> : [],
modeConfigs: Array.isArray(o.modeConfigs) ? o.modeConfigs as Array<Partial<IpsecSnapshotModeConfig>> : [],
pools: Array.isArray(o.pools) ? o.pools as Array<Partial<IpsecSnapshotPool>> : [],
policies: Array.isArray(o.policies) ? o.policies as Array<Partial<IpsecSnapshotPolicy>> : [],
nat: Array.isArray(o.nat) ? o.nat as Array<Partial<IpsecSnapshotNat>> : [],
})
}
function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
return compactBody({
name: p.name,
address: p.address,
"exchange-mode": p.exchangeMode,
passive: rosYesNo(p.passive),
certificate: p.certificate,
"send-cert": "always",
profile: p.profile,
comment: p.comment,
disabled: rosYesNo(p.disabled),
})
}
function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
return compactBody({
peer: i.peerName,
"auth-method": i.authMethod,
certificate: i.certificate,
"remote-certificate": i.remoteCertificate,
"match-by": i.matchBy,
secret: isHiddenSecret(i.secret) ? undefined : i.secret,
"remote-id": i.remoteId,
"mode-config": i.modeConfig,
"generate-policy": i.generatePolicy,
comment: i.comment,
disabled: rosYesNo(i.disabled),
})
}
function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string> {
return compactBody({
name: m.name,
"address-pool": m.addressPool,
address: m.address,
"static-dns": m.staticDns,
comment: m.comment,
})
}
function ipsecPoolBody(p: IpsecSnapshotPool): Record<string, string> {
return compactBody({ name: p.name, ranges: p.ranges, comment: p.comment })
}
function ipsecNatBody(n: IpsecSnapshotNat): Record<string, string> {
return compactBody({
chain: n.chain,
action: n.action,
"src-address": n.srcAddress,
comment: n.comment,
})
}
/** Restore только managed-объектов IKEv2 (peer/identity/mode-config/pool/nat; секреты (hidden) не перезаписываем;
* чужие (не ipsec-managed) live-объекты не трогаем даже если их передали). */
export function planIpsecRestore(
desiredInput: IpsecSnapshot,
current: {
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
},
): RosWriteOp[] {
const desired = canonicalIpsecSnapshot(desiredInput)
const ops: RosWriteOp[] = []
const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment))
const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.comment))
const managedModeConfigs = current.modeConfigs.filter((m) => isIpsecManagedComment(m.comment))
const managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment))
const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment))
for (const identity of managedIdentities) {
if (!desired.identities.some((i) => i.comment === identity.comment)) {
ops.push({ op: "delete", path: `/ip/ipsec/identity/${identity.rosId}` })
}
}
for (const mc of managedModeConfigs) {
if (!desired.modeConfigs.some((m) => m.name === mc.name)) {
ops.push({ op: "delete", path: `/ip/ipsec/mode-config/${mc.rosId}` })
}
}
for (const peer of managedPeers) {
if (!desired.peers.some((p) => p.name === peer.name)) {
ops.push({ op: "delete", path: `/ip/ipsec/peer/${peer.rosId}` })
}
}
for (const pool of managedPools) {
if (!desired.pools.some((p) => p.name === pool.name)) {
ops.push({ op: "delete", path: `/ip/pool/${pool.rosId}` })
}
}
for (const rule of managedNat) {
if (!desired.nat.some((n) => n.comment === rule.comment)) {
ops.push({ op: "delete", path: `/ip/firewall/nat/${rule.rosId}` })
}
}
for (const want of desired.peers) {
const live = managedPeers.find((p) => p.name === want.name)
const body = ipsecPeerBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/peer", body })
else ops.push({ op: "patch", path: `/ip/ipsec/peer/${live.rosId}`, body })
}
for (const want of desired.modeConfigs) {
const live = managedModeConfigs.find((m) => m.name === want.name)
const body = ipsecModeConfigBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/mode-config", body })
else ops.push({ op: "patch", path: `/ip/ipsec/mode-config/${live.rosId}`, body })
}
for (const want of desired.pools) {
const live = managedPools.find((p) => p.name === want.name)
const body = ipsecPoolBody(want)
if (!live) ops.push({ op: "put", path: "/ip/pool", body })
else ops.push({ op: "patch", path: `/ip/pool/${live.rosId}`, body })
}
for (const want of desired.identities) {
const live = managedIdentities.find((i) => i.comment === want.comment)
const body = ipsecIdentityBody(want)
if (!live) ops.push({ op: "put", path: "/ip/ipsec/identity", body })
else ops.push({ op: "patch", path: `/ip/ipsec/identity/${live.rosId}`, body })
}
for (const want of desired.nat) {
const live = managedNat.find((n) => n.comment === want.comment)
const body = ipsecNatBody(want)
if (!live) ops.push({ op: "put", path: "/ip/firewall/nat", body })
else ops.push({ op: "patch", path: `/ip/firewall/nat/${live.rosId}`, body })
}
return ops
}
export function canonicalGreSnapshot(input: {
tunnels?: Array<Partial<GreSnapshotTunnel>>
}): GreSnapshot {
+121
View File
@@ -0,0 +1,121 @@
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, clientCertName, ipsecManagedComment } from "./ipsec-config.js"
type RosCertRow = Record<string, string | undefined>
const SIGN_POLL_TIMEOUT_MS = 90_000
const SIGN_POLL_INTERVAL_MS = 1_000
function isIp(value: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value.trim())
}
export async function findCertificate(client: MikrotikClient, name: string): Promise<RosCertRow | undefined> {
const certs = await client.getCertificates()
return certs.find((c) => String(c.name ?? "").trim() === name)
}
/** Сертификат подписан: у него заполнен invalid-after. */
export async function isCertificateSigned(client: MikrotikClient, name: string): Promise<boolean> {
const cert = await findCertificate(client, name)
return Boolean(cert && String(cert["invalid-after"] ?? "").trim() !== "")
}
/** sign в RouterOS не мгновенный: ждём появления invalid-after. */
export async function waitCertificateSigned(client: MikrotikClient, name: string, timeoutMs = SIGN_POLL_TIMEOUT_MS): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isCertificateSigned(client, name)) return
await new Promise((resolve) => setTimeout(resolve, SIGN_POLL_INTERVAL_MS))
}
throw new Error(`Сертификат ${name} не подписан за ${Math.round(timeoutMs / 1000)} с`)
}
/** Локальный CA для IKEv2 (self-signed); идемпотентно. Возвращает имя сертификата. */
export async function ensureCaCertificate(client: MikrotikClient, daysValid: number): Promise<string> {
const existing = await findCertificate(client, IPSEC_CA_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_CA_CERT,
"common-name": "MikrotikManager IPsec CA",
"key-size": "4096",
"key-usage": "key-cert-sign,crl-sign",
"days-valid": String(daysValid),
comment: ipsecManagedComment("CA"),
})
}
if (!(await isCertificateSigned(client, IPSEC_CA_CERT))) {
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
await waitCertificateSigned(client, IPSEC_CA_CERT)
}
return IPSEC_CA_CERT
}
/** Серверный сертификат (CN/SAN = адрес, по которому стучатся клиенты); подписывается CA. */
export async function ensureServerCertificate(
client: MikrotikClient,
args: { serverEndpoint: string; caCertName: string; daysValid: number },
): Promise<string> {
const endpoint = args.serverEndpoint.trim()
const san = isIp(endpoint) ? `IP:${endpoint}` : `DNS:${endpoint}`
const existing = await findCertificate(client, IPSEC_SERVER_CERT)
if (!existing) {
await client.addCertificate({
name: IPSEC_SERVER_CERT,
"common-name": endpoint,
"subject-alt-name": san,
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-server",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment("server"),
})
}
if (!(await isCertificateSigned(client, IPSEC_SERVER_CERT))) {
await client.signCertificate({ name: IPSEC_SERVER_CERT, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, IPSEC_SERVER_CERT)
}
return IPSEC_SERVER_CERT
}
export interface IssuedClientCert {
certName: string
commonName: string
/** Сертификат с этим CN уже существовал (перевыпуск не выполнялся). */
existed: boolean
}
/** Клиентский сертификат: add + sign CA. CN = имя пользователя. */
export async function issueClientCertificate(
client: MikrotikClient,
args: { userName: string; caCertName: string; daysValid: number },
): Promise<IssuedClientCert> {
const certName = clientCertName(args.userName)
const existing = await findCertificate(client, certName)
if (existing) {
return { certName, commonName: String(existing["common-name"] ?? args.userName), existed: true }
}
await client.addCertificate({
name: certName,
"common-name": args.userName.trim(),
"key-size": "2048",
"key-usage": "digital-signature,key-encipherment,tls-client",
"days-valid": String(args.daysValid),
comment: ipsecManagedComment(`client ${args.userName.trim()}`),
})
await client.signCertificate({ name: certName, ca: args.caCertName, daysValid: args.daysValid })
await waitCertificateSigned(client, certName)
return { certName, commonName: args.userName.trim(), existed: false }
}
/** Экспорт клиентского .p12 (сертификат + ключ; CA в цепочке) с роутера. */
export async function exportClientP12(
client: MikrotikClient,
userName: string,
passphrase: string,
): Promise<{ fileName: string; content: Buffer; certName: string }> {
const certName = clientCertName(userName)
const cert = await findCertificate(client, certName)
if (!cert) throw new Error(`Сертификат ${certName} не найден на роутере`)
const { fileName, content } = await client.exportCertificatePkcs12({ name: certName, passphrase })
return { fileName, content, certName }
}
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict"
import {
IPSEC_MARKER,
buildClientInstructions,
buildSswanConfig,
clientCertName,
findFreePoolIp,
ipsecManagedComment,
ipsecSlug,
ipsecUserComment,
isIpsecManagedComment,
parseIpsecUserComment,
poolRangesFromCidr,
userModeConfigName,
} from "./ipsec-config.js"
{
assert.equal(ipsecSlug("Alice Cooper"), "alice-cooper")
assert.equal(ipsecSlug("Иван"), "")
assert.equal(clientCertName("Alice Cooper"), "ipsec-user-alice-cooper")
assert.equal(userModeConfigName("Bob"), "mc-ipsec-bob")
}
{
assert.ok(isIpsecManagedComment(ipsecManagedComment("IKEv2 road-warrior")))
assert.ok(isIpsecManagedComment(`${IPSEC_MARKER} user=alice`))
assert.ok(!isIpsecManagedComment("какой-то чужой комментарий"))
assert.ok(!isIpsecManagedComment(undefined))
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice")
assert.equal(parseIpsecUserComment("не managed"), null)
assert.equal(parseIpsecUserComment(null), null)
}
{
assert.equal(poolRangesFromCidr("10.77.0.0/24"), "10.77.0.2-10.77.0.254")
assert.equal(poolRangesFromCidr("10.77.0.1/24"), "10.77.0.2-10.77.0.254")
assert.equal(poolRangesFromCidr("192.168.7.0/28"), "192.168.7.2-192.168.7.14")
assert.equal(poolRangesFromCidr("10.77.0.0/31"), null, "/31 — нет адресов клиентам")
assert.equal(poolRangesFromCidr("10.77.0.0"), null)
assert.equal(poolRangesFromCidr("не-сидр"), null)
}
{
const range = "10.77.0.2-10.77.0.10"
assert.equal(findFreePoolIp(range, []), "10.77.0.2")
assert.equal(findFreePoolIp(range, ["10.77.0.2"]), "10.77.0.3")
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3/32", "10.77.0.4"]), "10.77.0.5")
assert.equal(findFreePoolIp(range, ["10.77.0.2", "10.77.0.3", "10.77.0.4", "10.77.0.5", "10.77.0.6", "10.77.0.7", "10.77.0.8", "10.77.0.9", "10.77.0.10"]), null)
// одиночный адрес без диапазона
assert.equal(findFreePoolIp("10.77.0.7", []), "10.77.0.7")
assert.equal(findFreePoolIp("мусор", []), null)
}
{
const sswan = buildSswanConfig({
name: "IKEv2 vpn.example.com",
serverEndpoint: "vpn.example.com",
serverId: "vpn.example.com",
p12B64: "cDEy",
uuid: "fix-me",
})
const parsed = JSON.parse(sswan) as Record<string, unknown>
assert.equal(parsed.version, 1)
assert.equal(parsed.type, "ikev2-cert")
const remote = parsed.remote as Record<string, string>
const local = parsed.local as Record<string, string>
assert.equal(remote.addr, "vpn.example.com")
assert.equal(remote.id, "vpn.example.com")
assert.equal(local.p12, "cDEy")
}
{
const text = buildClientInstructions({
userName: "alice",
serverEndpoint: "vpn.example.com",
p12Filename: "cert_export_ipsec-user-alice.p12",
passphrase: "s3cret",
dns: "10.77.0.1",
})
assert.ok(text.includes("alice"))
assert.ok(text.includes("vpn.example.com"))
assert.ok(text.includes("s3cret"))
assert.ok(text.includes("strongSwan"))
}
console.log("ipsec-config.test.ts: ok")
+158
View File
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto"
import { PRODUCT_NAME } from "../managed-markers.js"
// ── managed-маркеры (как у filters/recursive, см. managed-markers.ts) ─────────
export const IPSEC_MARKER = `${PRODUCT_NAME}:ipsec`
export function ipsecManagedComment(label = ""): string {
return label ? `${IPSEC_MARKER} ${label}` : IPSEC_MARKER
}
export function isIpsecManagedComment(comment: string | undefined | null): boolean {
return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER)
}
/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
export function ipsecUserComment(name: string): string {
return `${IPSEC_MARKER} user=${name.trim()}`
}
export function parseIpsecUserComment(comment: string | undefined | null): string | null {
if (!comment) return null
const m = /user=([^\s]+)/.exec(comment.trim())
return m?.[1] ?? null
}
// ── naming-конвенции управляемых объектов RouterOS ──────────────────────────
export const IPSEC_CA_CERT = "ipsec-ca"
export const IPSEC_SERVER_CERT = "ipsec-server"
/** Общее имя peer / profile / proposal / пула / общего mode-config. */
export const IPSEC_COMMON_NAME = "ipsec-vpn"
export function ipsecSlug(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
export function clientCertName(userName: string): string {
return `ipsec-user-${ipsecSlug(userName) || "client"}`
}
export function userModeConfigName(userName: string): string {
return `mc-ipsec-${ipsecSlug(userName) || "client"}`
}
// ── пул адресов клиентов ────────────────────────────────────────────────────
function ipToInt(b: Array<number | undefined>): number {
return (((b[0]! << 24) | (b[1]! << 16) | (b[2]! << 8) | b[3]!) >>> 0)
}
function intToIp(n: number): string {
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")
}
function parseIpv4(s: string): number | null {
const parts = s.trim().split(".")
if (parts.length !== 4) return null
const octets = parts.map((p) => Number(p))
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null
return ipToInt(octets)
}
/** «10.77.0.0/24» → pool ranges «10.77.0.2-10.77.0.254» (шлюзы .1 и broadcast не раздаём). */
export function poolRangesFromCidr(cidr: string): string | null {
const m = /^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/.exec(cidr.trim())
if (!m) return null
const base = parseIpv4(m[1]!)
const prefix = Number(m[2])
if (base == null || !Number.isInteger(prefix) || prefix < 16 || prefix > 30) return null
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
const network = (base & mask) >>> 0
const size = 2 ** (32 - prefix)
const from = network + 2
const to = network + size - 2
if (to <= from) return null
return `${intToIp(from)}-${intToIp(to)}`
}
export interface PoolIp {
ip: string
n: number
}
/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e», берём первый диапазон из списка), исключая занятые. */
export function findFreePoolIp(range: string, taken: Iterable<string>): string | null {
const takenSet = new Set(
Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()),
)
const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? ""
const [fromRaw, toRaw] = first.split("-")
const from = parseIpv4(fromRaw ?? "")
const to = parseIpv4(toRaw ?? fromRaw ?? "")
if (from == null) return null
const last = to ?? from
if (last < from) return null
const cap = Math.min(last, from + 65_534)
for (let n = from; n <= cap; n++) {
const ip = intToIp(n)
if (!takenSet.has(ip)) return ip
}
return null
}
// ── клиентские конфиги ──────────────────────────────────────────────────────
/** strongSwan (Android/iOS) .sswan-профиль с встроенным .p12. */
export function buildSswanConfig(args: {
name: string
serverEndpoint: string
serverId: string
p12B64: string
uuid?: string
}): string {
return JSON.stringify(
{
version: 1,
uuid: args.uuid ?? randomUUID(),
name: args.name,
type: "ikev2-cert",
remote: { addr: args.serverEndpoint, id: args.serverId },
local: { p12: args.p12B64 },
"ike-proposal": "AES256-SHA256-MODP2048",
"esp-proposal": "AES256-SHA256-MODP2048",
},
null,
2,
)
}
/** Короткая текстовая инструкция по подключению (Windows/macOS/iOS/Android). */
export function buildClientInstructions(args: {
userName: string
serverEndpoint: string
p12Filename: string
passphrase?: string
dns?: string
}): string {
const pass = args.passphrase ? `Пароль архива (при импорте): ${args.passphrase}\n` : ""
return [
`IKEv2/IPsec VPN — клиент «${args.userName}»`,
`Сервер: ${args.serverEndpoint}${args.dns ? ` (DNS: ${args.dns})` : ""}`,
"",
`1. Скачайте и импортируйте сертификат ${args.p12Filename}.`,
pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.",
"2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»",
" (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).",
"3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),",
" аутентификация — сертификат; удалённый ID = CN серверного сертификата.",
"4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).",
"",
"Адрес выдаётся автоматически при подключении (mode-config).",
].join("\n")
}
+513
View File
@@ -0,0 +1,513 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type {
IpsecCertInfoDto,
IpsecClientDto,
IpsecListResponse,
IpsecModeConfigDto,
IpsecPeerDto,
IpsecPoolDto,
IpsecServerSummaryDto,
} from "@mmapp/contracts/ipsec"
import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT,
clientCertName,
isIpsecManagedComment,
parseIpsecUserComment,
} from "./ipsec-config.js"
import {
canonicalIpsecSnapshot,
type IpsecLiveIdentity,
type IpsecLiveModeConfig,
type IpsecLiveNat,
type IpsecLivePeer,
type IpsecLivePool,
type IpsecSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect
export interface RosIpsecPeer {
".id"?: string
name?: string
address?: string
"exchange-mode"?: string
passive?: string
certificate?: string
profile?: string
disabled?: string
comment?: string
}
export interface RosIpsecIdentity {
".id"?: string
peer?: string
"auth-method"?: string
certificate?: string
"remote-certificate"?: string
"match-by"?: string
secret?: string
"remote-id"?: string
"mode-config"?: string
"generate-policy"?: string
disabled?: string
comment?: string
}
export interface RosIpsecModeConfig {
".id"?: string
name?: string
"address-pool"?: string
"address-prefix"?: string
address?: string
"split-dns"?: string
"static-dns"?: string
comment?: string
}
export interface RosIpsecPool {
".id"?: string
name?: string
ranges?: string
comment?: string
}
export interface RosIpsecPolicy {
".id"?: string
"src-address"?: string
"dst-address"?: string
proposal?: string
template?: string
comment?: string
}
export interface RosFirewallNat {
".id"?: string
chain?: string
action?: string
"src-address"?: string
comment?: string
}
export interface RosIpsecActivePeer {
".id"?: string
address?: string
"remote-id"?: string
identity?: string
established?: string
}
type RosCertRow = Record<string, string | undefined>
function asBool(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapCertificate(c: RosCertRow): IpsecCertInfoDto {
const name = String(c.name ?? "")
const keyUsage = String(c["key-usage"] ?? "")
const isCa = keyUsage.includes("key-cert-sign")
const isUser = name.startsWith("ipsec-user-")
return {
name,
commonName: c["common-name"] || undefined,
keySize: c["key-size"] || undefined,
fingerprint: c.fingerprint || undefined,
expiresAt: c["invalid-after"] || undefined,
trusted: asBool(c.trusted),
hasPrivateKey: asBool(c["private-key"]),
role: isCa ? "ca" : name === IPSEC_SERVER_CERT ? "server" : isUser ? "client" : "other",
managed: isCa && name === IPSEC_CA_CERT
|| name === IPSEC_SERVER_CERT
|| isUser
|| isIpsecManagedComment(c.comment),
}
}
function mapPeer(server: ServerRow, p: RosIpsecPeer): IpsecPeerDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "peer")}`,
rosId: String(p[".id"] ?? p.name ?? "peer"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name: (p.name ?? "").trim(),
address: p.address || undefined,
exchangeMode: p["exchange-mode"] || undefined,
passive: asBool(p.passive),
certificate: p.certificate || undefined,
profile: p.profile || undefined,
disabled: asBool(p.disabled),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
function mapModeConfig(server: ServerRow, m: RosIpsecModeConfig): IpsecModeConfigDto {
return {
id: `${server.id}:${String(m[".id"] ?? m.name ?? "mc")}`,
rosId: String(m[".id"] ?? m.name ?? "mc"),
serverId: String(server.id),
name: (m.name ?? "").trim(),
addressPool: m["address-pool"] || m["address-prefix"] || undefined,
address: m.address || undefined,
splitDns: m["split-dns"] || undefined,
staticDns: m["static-dns"] || undefined,
comment: m.comment || undefined,
managed: isIpsecManagedComment(m.comment),
}
}
function mapPool(server: ServerRow, p: RosIpsecPool): IpsecPoolDto {
return {
id: `${server.id}:${String(p[".id"] ?? p.name ?? "pool")}`,
rosId: String(p[".id"] ?? p.name ?? "pool"),
serverId: String(server.id),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: p.comment || undefined,
managed: isIpsecManagedComment(p.comment),
}
}
export interface IpsecServerState {
server: ServerRow
client: MikrotikClient
peers: RosIpsecPeer[]
identities: RosIpsecIdentity[]
modeConfigs: RosIpsecModeConfig[]
pools: RosIpsecPool[]
policies: RosIpsecPolicy[]
nat: RosFirewallNat[]
active: RosIpsecActivePeer[]
certs: RosCertRow[]
}
export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerState> {
const client = MikrotikClient.fromServer(server)
const empty = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
client.get<unknown>("/ip/ipsec/peer").then((v) => empty<RosIpsecPeer>(v)).catch(() => [] as RosIpsecPeer[]),
client.get<unknown>("/ip/ipsec/identity").then((v) => empty<RosIpsecIdentity>(v)).catch(() => [] as RosIpsecIdentity[]),
client.get<unknown>("/ip/ipsec/mode-config").then((v) => empty<RosIpsecModeConfig>(v)).catch(() => [] as RosIpsecModeConfig[]),
client.get<unknown>("/ip/pool").then((v) => empty<RosIpsecPool>(v)).catch(() => [] as RosIpsecPool[]),
client.get<unknown>("/ip/ipsec/policy").then((v) => empty<RosIpsecPolicy>(v)).catch(() => [] as RosIpsecPolicy[]),
client.get<unknown>("/ip/firewall/nat").then((v) => empty<RosFirewallNat>(v)).catch(() => [] as RosFirewallNat[]),
client.get<unknown>("/ip/ipsec/active-peers").then((v) => empty<RosIpsecActivePeer>(v)).catch(() => [] as RosIpsecActivePeer[]),
client.getCertificates().catch(() => [] as RosCertRow[]),
])
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
}
/** Клиенты = managed-идентичности (созданы менеджером). */
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const server = state.server
const certByCommonName = new Map<string, RosCertRow>()
for (const c of state.certs) {
const cn = String(c["common-name"] ?? "").trim()
if (cn) certByCommonName.set(cn, c)
}
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
const activeByRemote = new Map<string, RosIpsecActivePeer>()
for (const a of state.active) {
const rid = String(a["remote-id"] ?? "").trim()
if (rid) activeByRemote.set(rid, a)
}
return state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i): IpsecClientDto => {
const comment = i.comment ?? ""
const name = parseIpsecUserComment(comment) ?? (comment || "client")
const psk = (i["auth-method"] ?? "") === "pre-shared-key"
const certName = (i["remote-certificate"] ?? "").trim()
const cn = certName
? String(state.certs.find((c) => String(c.name ?? "") === certName)?.["common-name"] ?? "")
: ""
const mcName = (i["mode-config"] ?? "").trim()
const mc = mcByName.get(mcName)
const staticIp = mc && (mc.name ?? "").startsWith("mc-ipsec-")
? (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "") || undefined
: undefined
const active = (cn ? activeByRemote.get(cn) : undefined)
?? (i["remote-id"] ? activeByRemote.get(i["remote-id"]) : undefined)
return {
id: `${server.id}:${String(i[".id"] ?? "identity")}`,
rosId: String(i[".id"] ?? "identity"),
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
name,
authMethod: psk ? "pre-shared-key" : "certificate",
certificateName: certName || undefined,
commonName: cn || undefined,
remoteId: (i["remote-id"] ?? "").trim() || undefined,
staticIp,
modeConfigName: mcName || undefined,
peerName: (i.peer ?? "").trim() || undefined,
online: Boolean(active),
activeAddress: active?.address || undefined,
activeSince: active?.established || undefined,
disabled: asBool(i.disabled),
comment: comment || undefined,
managed: true,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
const server = state.server
const managedPeer = state.peers.find((p) => isIpsecManagedComment(p.comment))
?? state.peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
const sharedMc = state.modeConfigs.find(
(m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME || (isIpsecManagedComment(m.comment) && !(m.name ?? "").startsWith("mc-ipsec-")),
)
const pool = sharedMc
? state.pools.find((p) => (p.name ?? "").trim() === (sharedMc["address-pool"] ?? "").trim())
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined
const caCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_CA_CERT)
const serverCert = state.certs.find((c) => String(c.name ?? "") === IPSEC_SERVER_CERT)
const natRuleManaged = state.nat.some((r) => isIpsecManagedComment(r.comment) && r.chain === "srcnat")
return {
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
initialized: Boolean(managedPeer && caCert && serverCert),
serverEndpoint: serverCert ? String(serverCert["common-name"] ?? "") || undefined : undefined,
peer: managedPeer ? mapPeer(server, managedPeer) : undefined,
pool: pool ? mapPool(server, pool) : undefined,
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
caCert: caCert ? mapCertificate(caCert) : undefined,
serverCert: serverCert ? mapCertificate(serverCert) : undefined,
natRuleManaged,
clientsTotal: clients.length,
clientsOnline: clients.filter((c) => c.online).length,
certs: state.certs
.filter((c) => {
const n = String(c.name ?? "")
return n === IPSEC_CA_CERT || n === IPSEC_SERVER_CERT || n.startsWith("ipsec-user-")
})
.map(mapCertificate),
}
}
export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnapshot> {
const state = await fetchIpsecState(server)
return canonicalIpsecSnapshot({
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "").trim(),
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
policies: state.policies
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
srcAddress: (p["src-address"] ?? "").trim(),
dstAddress: (p["dst-address"] ?? "").trim(),
proposal: (p.proposal ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
})
}
export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
client: MikrotikClient
peers: IpsecLivePeer[]
identities: IpsecLiveIdentity[]
modeConfigs: IpsecLiveModeConfig[]
pools: IpsecLivePool[]
nat: IpsecLiveNat[]
snapshot: IpsecSnapshot
}> {
const state = await fetchIpsecState(server)
return {
client: state.client,
peers: state.peers
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
address: (p.address ?? "").trim(),
exchangeMode: (p["exchange-mode"] ?? "").trim(),
passive: asBool(p.passive),
certificate: (p.certificate ?? "").trim(),
profile: (p.profile ?? "").trim(),
comment: (p.comment ?? "").trim(),
disabled: asBool(p.disabled),
})),
identities: state.identities
.filter((i) => isIpsecManagedComment(i.comment))
.map((i) => ({
rosId: String(i[".id"] ?? ""),
peerName: (i.peer ?? "").trim(),
authMethod: (i["auth-method"] ?? "") === "pre-shared-key" ? "pre-shared-key" as const : "rsa-key" as const,
certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(),
secret: (i.secret ?? "").trim(),
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment))
.map((m) => ({
rosId: String(m[".id"] ?? ""),
name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(),
address: (m.address ?? m["address-prefix"] ?? "").trim(),
staticDns: (m["static-dns"] ?? "").trim(),
comment: (m.comment ?? "").trim(),
})),
pools: state.pools
.filter((p) => isIpsecManagedComment(p.comment))
.map((p) => ({
rosId: String(p[".id"] ?? ""),
name: (p.name ?? "").trim(),
ranges: (p.ranges ?? "").trim(),
comment: (p.comment ?? "").trim(),
})),
nat: state.nat
.filter((n) => isIpsecManagedComment(n.comment))
.map((n) => ({
rosId: String(n[".id"] ?? ""),
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
comment: (n.comment ?? "").trim(),
})),
snapshot: await captureIpsecSnapshot(server),
}
}
export type IpsecListResult = IpsecListResponse
export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecListResult> {
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: IpsecListResult["failures"] = []
const summaries: IpsecServerSummaryDto[] = []
const clients: IpsecClientDto[] = []
await Promise.all(
serverRows.map(async (server) => {
try {
const state = await fetchIpsecState(server)
const serverClients = mapClients(state)
summaries.push(mapServerSummary(state, serverClients))
clients.push(...serverClients)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
}
}),
)
summaries.sort((a, b) => a.serverName.localeCompare(b.serverName))
clients.sort((a, b) => a.name.localeCompare(b.name))
return { servers: summaries, clients, failures }
}
export async function countIpsecClients(): Promise<number> {
try {
const result = await Promise.race([
listIpsec(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.clients.length
} catch {
return 0
}
}
export async function getEnabledIpsecServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
export async function listIpsecClientsForCatalog(serverId: number): Promise<{
clients: IpsecClientDto[]
error?: string
}> {
const row = await getEnabledIpsecServerById(serverId)
if (!row) return { clients: [], error: "Сервер не найден" }
try {
const state = await Promise.race([
fetchIpsecState(row),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут RouterOS")), 5_000)
}),
])
return { clients: mapClients(state) }
} catch (e) {
return { clients: [], error: e instanceof Error ? e.message : String(e) }
}
}
export { clientCertName }
+214
View File
@@ -0,0 +1,214 @@
import type { MikrotikClient } from "./mikrotik.js"
import { ipsecManagedComment } from "./ipsec-config.js"
export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v == null) continue
const s = String(v)
if (s === "") continue
out[k] = s
}
return out
}
type RosRow = Record<string, string | undefined>
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
const raw = await client.get<unknown>(path).catch(() => [])
return Array.isArray(raw) ? (raw as RosRow[]) : []
}
/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
export async function putOrPatchByName(
client: MikrotikClient,
path: string,
name: string,
body: Record<string, string>,
): Promise<"created" | "patched"> {
const rows = await listByPath(client, path)
const existing = rows.find((r) => String(r.name ?? "").trim() === name)
if (existing?.[".id"]) {
await client.patch(`${path}/${encodeURIComponent(existing[".id"])}`, body)
return "patched"
}
await client.put(path, body)
return "created"
}
async function findRosId(client: MikrotikClient, path: string, name: string): Promise<string | null> {
const rows = await listByPath(client, path)
return rows.find((r) => String(r.name ?? "").trim() === name)?.[".id"] ?? null
}
// ── инициализация IKEv2-сервера ─────────────────────────────────────────────
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
name,
"hash-algorithm": "sha256",
"enc-algorithm": "aes-256,aes-192,aes-128",
"dh-group": "modp2048,modp1536,modp1024",
"nat-traversal": "yes",
comment,
}))
}
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
name,
"auth-algorithms": "sha256,sha1",
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
"pfs-group": "modp2048",
comment,
}))
}
export async function ensureIpsecPool(client: MikrotikClient, name: string, ranges: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/pool", name, toRosBody({ name, ranges, comment }))
}
/** Общий mode-config: адрес клиентам из пула. */
export async function ensureSharedModeConfig(
client: MikrotikClient,
name: string,
poolName: string,
dns: string | undefined,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
"address-pool": poolName,
"static-dns": dns,
comment,
}))
}
export async function ensurePolicyTemplate(
client: MikrotikClient,
dstCidr: string,
proposalName: string,
comment: string,
): Promise<void> {
const rows = await listByPath(client, "/ip/ipsec/policy")
const managed = rows.find((r) => (r.comment ?? "").trim() === comment && (r.template === "true" || r.template === "yes"))
if (managed?.[".id"]) {
await client.patch(`/ip/ipsec/policy/${encodeURIComponent(managed[".id"])}`, toRosBody({
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
comment,
}))
return
}
await client.put("/ip/ipsec/policy", toRosBody({
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
template: "yes",
comment,
}))
}
export async function ensureIpsecPeer(
client: MikrotikClient,
name: string,
serverCertName: string,
profileName: string,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/peer", name, toRosBody({
name,
address: "0.0.0.0/0",
"exchange-mode": "ike2",
passive: "yes",
certificate: serverCertName,
"send-cert": "always",
profile: profileName,
comment,
}))
}
/** Managed srcnat masquerade: интернет клиентам VPN. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise<void> {
const rows = await listByPath(client, "/ip/firewall/nat")
const existing = rows.find(
(r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat",
)
if (existing?.[".id"]) return
await client.put("/ip/firewall/nat", toRosBody({
chain: "srcnat",
action: "masquerade",
"src-address": poolCidr,
"out-interface-list": "WAN",
comment,
}))
}
// ── клиенты ─────────────────────────────────────────────────────────────────
export async function putUserModeConfig(
client: MikrotikClient,
name: string,
staticIp: string,
comment: string,
): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name,
address: staticIp,
comment,
}))
}
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
const id = await findRosId(client, "/ip/ipsec/mode-config", name)
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`)
}
export interface IdentityFields {
peerName: string
modeConfig: string
comment: string
authMethod: "certificate" | "pre-shared-key"
/** cert: серверный сертификат (router представляет его клиенту). */
certificate?: string
/** cert: строгий мэтч конкретного клиента по его сертификату. */
remoteCertificate?: string
/** psk. */
secret?: string
remoteId?: string
}
export function identityRosBody(f: IdentityFields): Record<string, string> {
return toRosBody({
peer: f.peerName,
"auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key",
certificate: f.certificate,
"remote-certificate": f.remoteCertificate,
"match-by": f.authMethod === "certificate" ? "certificate" : undefined,
secret: f.secret,
"remote-id": f.remoteId,
"mode-config": f.modeConfig,
"generate-policy": "port-strict",
comment: f.comment,
})
}
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
await client.put("/ip/ipsec/identity", identityRosBody(fields))
}
export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
}
export async function deleteIdentity(client: MikrotikClient, rosId: string): Promise<void> {
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
}
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
}
export { listByPath, findRosId, ipsecManagedComment }
+137
View File
@@ -267,6 +267,57 @@ function rosDelete(
})
}
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON-ответ = ошибка. */
function rosDownload(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "GET",
headers: { Authorization: authHeader },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const req = lib.request(options, (res) => {
const chunks: Buffer[] = []
res.on("data", (chunk: Buffer) => { chunks.push(chunk) })
res.on("end", () => {
const buf = Buffer.concat(chunks)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf.toString("utf8").slice(0, 200)))
return
}
const contentType = String(res.headers["content-type"] ?? "")
if (contentType.includes("application/json")) {
reject(new Error(`RouterOS вернул метаданные вместо содержимого файла ${path}`))
return
}
resolve(buf)
})
})
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
req.on("close", () => clearTimeout(timer))
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
function rosPatch(
params: MikrotikConnectParams,
path: string,
@@ -621,6 +672,92 @@ export class MikrotikClient {
}
}
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */
async downloadFile(fileName: string, timeoutMs = 30_000): Promise<Buffer> {
const normalized = routerFileBasename(fileName)
const candidates = [normalized, `flash/${normalized}`]
let lastError: unknown
for (const name of candidates) {
try {
return await rosDownload(this.params, `/file/${encodeURIComponent(name)}`, timeoutMs)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Не удалось скачать файл ${normalized} с RouterOS`)
}
/** Создание ключевой пары + заявки: /certificate add (поля common-name, key-size, key-usage…). */
async addCertificate(body: Record<string, string>, timeoutMs = 30_000): Promise<unknown> {
return this.post("/certificate/add", body, timeoutMs)
}
/** Подпись сертификата локальным CA; sign небыстрый — увеличенный таймаут. */
async signCertificate(params: {
name: string
ca?: string
daysValid?: number
}, timeoutMs = 120_000): Promise<unknown> {
const body: Record<string, string> = { name: params.name }
if (params.ca) body.ca = params.ca
if (params.daysValid != null) body["days-valid"] = String(params.daysValid)
try {
return await this.post("/certificate/sign", body, timeoutMs)
} catch (e) {
// Некоторые версии REST принимают цель подписи только как .id.
const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === params.name)
const id = row?.[".id"]
if (!id) throw e
return await this.post("/certificate/sign", { ".id": id, ...body }, timeoutMs)
}
}
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла. */
async exportCertificate(params: {
name: string
type: "pkcs12" | "pem"
passphrase?: string
}, timeoutMs = 60_000): Promise<string> {
const body: Record<string, string> = { name: params.name, type: params.type }
if (params.passphrase?.trim()) body["export-passphrase"] = params.passphrase.trim()
let raw: unknown
try {
raw = await this.post("/certificate/export-certificate", body, timeoutMs)
} catch (e) {
const certs = await this.getCertificates()
const id = certs.find((c) => String(c.name ?? "") === params.name)?.[".id"]
if (!id) throw e
raw = await this.post("/certificate/export-certificate", { ".id": id, ...body }, timeoutMs)
}
void raw
// RouterOS создаёт cert_export_<name>.p12 либо <name>.p12 — ищем по списку файлов.
const ext = params.type === "pkcs12" ? "p12" : "crt"
const wanted = [`${params.name}.${ext}`, `cert_export_${params.name}.${ext}`]
const files = await this.listFiles()
const hit = files.find((f) => wanted.includes(f.name))
?? files.find((f) => f.name.endsWith(`.${ext}`) && f.name.includes(params.name))
if (!hit) throw new Error(`Файл экспорта ${params.name}.${ext} не найден на RouterOS`)
return hit.name
}
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
async exportCertificatePkcs12(params: { name: string; passphrase?: string }): Promise<{ fileName: string; content: Buffer }> {
const fileName = await this.exportCertificate({ name: params.name, type: "pkcs12", passphrase: params.passphrase })
const content = await this.downloadFile(fileName)
return { fileName, content }
}
async removeCertificate(nameOrId: string, timeoutMs = 30_000): Promise<void> {
const certs = await this.getCertificates()
const row = certs.find((c) => String(c.name ?? "") === nameOrId || c[".id"] === nameOrId)
const id = row?.[".id"]
if (!id) return
await this.delete(`/certificate/${encodeURIComponent(id)}`, timeoutMs)
}
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
try {