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:
@@ -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