693 lines
27 KiB
TypeScript
693 lines
27 KiB
TypeScript
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
|
|
import { parseDbServerId } from "../utils/server-id.js"
|
|
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,
|
|
identityDisplayName,
|
|
isIke2RemoteAccessIdentity,
|
|
isIpsecManagedComment,
|
|
isManagedIpsecModeConfig,
|
|
resolveIke2CaName,
|
|
resolveIke2ServerCert,
|
|
selectIke2Peers,
|
|
selectSharedIke2Identity,
|
|
} from "./ipsec-config.js"
|
|
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.js"
|
|
import { routerOsCertificateDn } from "./ipsec-certificate-dn.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
|
|
"policy-template-group"?: 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
|
|
peer?: string
|
|
dynamic?: string
|
|
}
|
|
|
|
export interface RosFirewallNat {
|
|
".id"?: string
|
|
chain?: string
|
|
action?: string
|
|
"src-address"?: string
|
|
comment?: string
|
|
"out-interface"?: string
|
|
"out-interface-list"?: string
|
|
"ipsec-policy"?: string
|
|
}
|
|
|
|
export interface RosIpsecActivePeer {
|
|
".id"?: string
|
|
address?: string
|
|
"remote-id"?: string
|
|
identity?: string
|
|
established?: string
|
|
id?: string
|
|
state?: string
|
|
"remote-address"?: string
|
|
"dynamic-address"?: string
|
|
uptime?: string
|
|
}
|
|
|
|
type RosCertRow = Record<string, string | undefined>
|
|
|
|
function asBool(v: string | undefined): boolean {
|
|
return v === "true" || v === "yes"
|
|
}
|
|
|
|
export function mapCertificate(c: RosCertRow, ctx: CertificateRoleContext = {}): IpsecCertInfoDto {
|
|
const name = String(c.name ?? "")
|
|
const isUser = name.startsWith("ipsec-user-")
|
|
const role = certificateRole(c, {
|
|
peerCertNames: ctx.peerCertNames,
|
|
identityCertNames: ctx.identityCertNames,
|
|
})
|
|
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,
|
|
signedBy: (c.ca ?? "").trim() || undefined,
|
|
managed: (name === IPSEC_CA_CERT && role === "ca")
|
|
|| 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: isManagedIpsecModeConfig(m),
|
|
}
|
|
}
|
|
|
|
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, reader?: MikrotikClient): Promise<IpsecServerState> {
|
|
if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
|
|
const client = reader ?? MikrotikClient.fromServer(server)
|
|
const collection = async <T>(path: string): Promise<T[]> => {
|
|
const raw = await client.get<unknown>(path)
|
|
if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"])) throw new Error(`Некорректный ответ IPsec: ${path}`)
|
|
return raw as T[]
|
|
}
|
|
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
|
|
collection<RosIpsecPeer>("/ip/ipsec/peer"), collection<RosIpsecIdentity>("/ip/ipsec/identity"),
|
|
collection<RosIpsecModeConfig>("/ip/ipsec/mode-config"), collection<RosIpsecPool>("/ip/pool"),
|
|
collection<RosIpsecPolicy>("/ip/ipsec/policy"), collection<RosFirewallNat>("/ip/firewall/nat"),
|
|
collection<RosIpsecActivePeer>("/ip/ipsec/active-peers"), collection<RosCertRow>("/certificate"),
|
|
])
|
|
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
|
}
|
|
|
|
/**
|
|
* Клиенты IKEv2:
|
|
* 1) клиентские сертификаты (роль client) — включая существующие client1/anakondra на устройстве;
|
|
* 2) PSK-identity.
|
|
* Общая listener-identity (без remote-certificate) клиентом не считается — она обслуживает всех.
|
|
*/
|
|
export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
|
const server = state.server
|
|
const ike2Peers = selectIke2Peers(state.peers)
|
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
|
const mcByName = new Map(state.modeConfigs.map((m) => [(m.name ?? "").trim(), m]))
|
|
const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
|
|
|
|
const roleCtx: CertificateRoleContext = {
|
|
peerCertNames: [...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...state.identities.filter(i => ike2PeerNames.includes((i.peer ?? "").trim())).map(i => (i.certificate ?? "").trim())].filter(Boolean),
|
|
identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
|
}
|
|
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities))
|
|
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
|
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
|
|
|
|
const activeByRemote = new Map<string, RosIpsecActivePeer>()
|
|
const ambiguousRemoteIds = new Set<string>()
|
|
for (const a of state.active) {
|
|
if (a.state !== "established") continue
|
|
const rid = String(a.id ?? a["remote-id"] ?? "").trim()
|
|
if (rid) {
|
|
if (activeByRemote.has(rid)) ambiguousRemoteIds.add(rid)
|
|
else activeByRemote.set(rid, a)
|
|
}
|
|
}
|
|
for (const rid of ambiguousRemoteIds) activeByRemote.delete(rid)
|
|
|
|
const subjectCounts = new Map<string, number>()
|
|
const cnCounts = new Map<string, number>()
|
|
for (const cert of state.certs) {
|
|
const subject = routerOsCertificateDn(cert)
|
|
if (subject) subjectCounts.set(subject, (subjectCounts.get(subject) ?? 0) + 1)
|
|
const cn = cert["common-name"]
|
|
if (cn) cnCounts.set(cn, (cnCounts.get(cn) ?? 0) + 1)
|
|
}
|
|
|
|
// RouterOS 7 active-peers.id contains the received value without its configured type prefix.
|
|
const findActive = (remoteId: string) => activeByRemote.get(remoteId)
|
|
?? activeByRemote.get(remoteId.replace(/^(key-id|fqdn|user-fqdn|address):/, ""))
|
|
|
|
const identityByRemoteCert = new Map<string, RosIpsecIdentity>()
|
|
for (const i of state.identities) {
|
|
const rc = (i["remote-certificate"] ?? "").trim()
|
|
if (rc) identityByRemoteCert.set(rc, i)
|
|
}
|
|
|
|
const personalStaticIp = (mcNameRaw: string): string | undefined => {
|
|
const mc = mcByName.get(mcNameRaw)
|
|
if (!mc) return undefined
|
|
return (mc.address ?? mc["address-prefix"] ?? "").replace(/\/\d+$/, "").trim() || undefined
|
|
}
|
|
|
|
const out: IpsecClientDto[] = []
|
|
const sessionClaims = new Map<RosIpsecActivePeer, IpsecClientDto[]>()
|
|
const claimSession = (active: RosIpsecActivePeer | undefined) => {
|
|
if (!active) return
|
|
const claims = sessionClaims.get(active) ?? []
|
|
claims.push(out[out.length - 1]!)
|
|
sessionClaims.set(active, claims)
|
|
}
|
|
|
|
// 1. Клиентские сертификаты (роль client), подписанные нашим CA / выданные менеджером / referenced identity.
|
|
for (const cert of state.certs) {
|
|
const certName = String(cert.name ?? "").trim()
|
|
if (!certName) continue
|
|
if (certificateRole(cert, roleCtx) !== "client") continue
|
|
const signedBy = (cert.ca ?? "").trim()
|
|
const isUser = certName.startsWith("ipsec-user-")
|
|
const referenced = identityByRemoteCert.has(certName)
|
|
if (!isUser && !referenced && !(caName && signedBy === caName)) continue
|
|
|
|
const identity = identityByRemoteCert.get(certName)
|
|
const comment = identity?.comment ?? ""
|
|
const mcName = (identity?.["mode-config"] ?? "").trim()
|
|
const cn = String(cert["common-name"] ?? "").trim()
|
|
const subject = routerOsCertificateDn(cert)
|
|
const bySubject = subject && subjectCounts.get(subject) === 1 ? activeByRemote.get(subject) : undefined
|
|
const uniqueCn = cn && cnCounts.get(cn) === 1
|
|
// A literal CN is not a typed remote-id. It may also resemble another
|
|
// certificate's complete DN, in which case the bare-CN alias is unsafe.
|
|
const byCommonName = uniqueCn && !subjectCounts.has(cn) ? activeByRemote.get(cn) : undefined
|
|
const active = bySubject ?? byCommonName
|
|
?? (identity?.["remote-id"] ? findActive(String(identity["remote-id"]).trim()) : undefined)
|
|
out.push({
|
|
id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
|
rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
|
serverId: String(server.id),
|
|
serverName,
|
|
name: identity ? identityDisplayName(identity, state.certs) : (cn || certName),
|
|
authMethod: "certificate",
|
|
kind: "cert",
|
|
certificateName: certName,
|
|
certName,
|
|
signedBy: signedBy || undefined,
|
|
commonName: cn || undefined,
|
|
remoteId: (identity?.["remote-id"] ?? "").trim() || undefined,
|
|
staticIp: personalStaticIp(mcName),
|
|
modeConfigName: mcName || undefined,
|
|
peerName: (identity?.peer ?? "").trim() || undefined,
|
|
online: Boolean(active),
|
|
activeAddress: active?.["dynamic-address"] || active?.["remote-address"] || active?.address || undefined,
|
|
activeSince: active?.uptime || active?.established || undefined,
|
|
disabled: asBool(identity?.disabled),
|
|
comment: comment || undefined,
|
|
managed: isUser || isIpsecManagedComment(comment),
|
|
})
|
|
claimSession(active)
|
|
}
|
|
|
|
// 2. PSK-identity (managed или IKEv2 remote-access).
|
|
for (const i of state.identities) {
|
|
if ((i["auth-method"] ?? "").trim().toLowerCase() !== "pre-shared-key") continue
|
|
const rosId = String(i[".id"] ?? "")
|
|
if (sharedRosId && rosId === sharedRosId) continue
|
|
const managed = isIpsecManagedComment(i.comment)
|
|
if (!managed && !isIke2RemoteAccessIdentity(i, ike2PeerNames)) continue
|
|
const mcName = (i["mode-config"] ?? "").trim()
|
|
const remoteId = (i["remote-id"] ?? "").trim()
|
|
const active = remoteId ? findActive(remoteId) : undefined
|
|
out.push({
|
|
id: `${server.id}:identity:${rosId}`,
|
|
rosId: `identity:${rosId}`,
|
|
serverId: String(server.id),
|
|
serverName,
|
|
name: identityDisplayName(i, state.certs),
|
|
authMethod: "pre-shared-key",
|
|
kind: "psk",
|
|
remoteId: remoteId || undefined,
|
|
staticIp: personalStaticIp(mcName),
|
|
modeConfigName: mcName || undefined,
|
|
peerName: (i.peer ?? "").trim() || undefined,
|
|
online: Boolean(active),
|
|
activeAddress: active?.["dynamic-address"] || active?.["remote-address"] || active?.address || undefined,
|
|
activeSince: active?.uptime || active?.established || undefined,
|
|
disabled: asBool(i.disabled),
|
|
comment: i.comment || undefined,
|
|
managed,
|
|
})
|
|
claimSession(active)
|
|
}
|
|
|
|
// The native snapshot does not identify the configured identity/auth type.
|
|
// Never assign one observed session/address to multiple catalog clients.
|
|
for (const claims of sessionClaims.values()) {
|
|
if (claims.length < 2) continue
|
|
for (const client of claims) {
|
|
client.online = false
|
|
client.sessionAmbiguous = true
|
|
client.activeAddress = undefined
|
|
client.activeSince = undefined
|
|
}
|
|
}
|
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
|
|
/** Explicit namespaces prevent cross-menu RouterOS ID collisions. Legacy ambiguous IDs fail closed. */
|
|
export function resolveIpsecClientTarget(state: IpsecServerState, token: string) {
|
|
const kind = token.startsWith("cert:") ? "cert" : token.startsWith("identity:") ? "identity" : undefined
|
|
const raw = kind ? token.slice(kind.length + 1) : token
|
|
const clients = mapClients(state)
|
|
const identity = kind === "cert" ? undefined : state.identities.find(i => i[".id"] === raw && clients.some(c => c.rosId === "identity:" + raw))
|
|
const certRow = kind === "identity" ? undefined : state.certs.find(c => (c[".id"] === raw || c.name === raw) && clients.some(user => user.kind === "cert" && user.certName === c.name))
|
|
return { identity, certRow, ambiguous: Boolean(!kind && identity && certRow) }
|
|
}
|
|
|
|
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
|
const server = state.server
|
|
const ike2Peers = selectIke2Peers(state.peers)
|
|
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
|
const ike2Identities = state.identities.filter(
|
|
(i) => isIpsecManagedComment(i.comment) || isIke2RemoteAccessIdentity(i, ike2PeerNames),
|
|
)
|
|
|
|
const managedPeer = ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
|
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
|
/** Для отображения: managed IKEv2 peer, иначе первый существующий IKEv2 peer. */
|
|
const primaryPeer = managedPeer ?? ike2Peers[0]
|
|
|
|
// mode-config, на который ссылаются IKEv2 identity (не персональный mc-ipsec-*), fallback managed shared
|
|
const idMcName = ike2Identities
|
|
.map((i) => (i["mode-config"] ?? "").trim())
|
|
.find((n) => n && !n.startsWith("mc-ipsec-"))
|
|
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
|
|
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
|
?? state.modeConfigs.find((m) => 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 serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
|
|
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
|
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
|
|
|
|
const roleCtx: CertificateRoleContext = {
|
|
peerCertNames: [...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...state.identities.filter(i => ike2PeerNames.includes((i.peer ?? "").trim())).map(i => (i.certificate ?? "").trim())].filter(Boolean),
|
|
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
|
}
|
|
const referencedCertNames = new Set<string>([
|
|
...(roleCtx.peerCertNames ?? []),
|
|
...(roleCtx.identityCertNames ?? []),
|
|
...ike2Identities.map((i) => (i.certificate ?? "").trim()).filter(Boolean),
|
|
])
|
|
// только IKEv2-релевантные серты: CA сервера, referenced peer/identity и подписанные этим CA
|
|
const relevantCerts = state.certs.filter((c) => {
|
|
const n = String(c.name ?? "").trim()
|
|
if (caName && n === caName) return true
|
|
if (referencedCertNames.has(n)) return true
|
|
return Boolean(caName) && (c.ca ?? "").trim() === caName
|
|
})
|
|
|
|
const caCert = caCertRow ? mapCertificate(caCertRow, roleCtx) : undefined
|
|
const serverCert = serverCertRow ? mapCertificate(serverCertRow, roleCtx) : undefined
|
|
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),
|
|
ike2Ready: Boolean(ike2Peers.length > 0 && caCert && serverCert),
|
|
serverEndpoint: serverCertRow ? String(serverCertRow["common-name"] ?? "") || undefined : undefined,
|
|
peer: primaryPeer ? mapPeer(server, primaryPeer) : undefined,
|
|
peers: ike2Peers.map((p) => mapPeer(server, p)),
|
|
pool: pool ? mapPool(server, pool) : undefined,
|
|
sharedModeConfig: sharedMc ? mapModeConfig(server, sharedMc) : undefined,
|
|
caCert,
|
|
serverCert,
|
|
natRuleManaged,
|
|
clientsTotal: clients.length,
|
|
clientsOnline: clients.filter((c) => c.online).length,
|
|
...(clients.some(c => c.sessionAmbiguous) ? { clientsSessionAmbiguous: clients.filter(c => c.sessionAmbiguous).length } : {}),
|
|
certs: relevantCerts.map((c) => mapCertificate(c, roleCtx)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Snapshot для истории/отката намеренно остаётся managed-only (`MikrotikManager:ipsec`):
|
|
* restore не должен трогать/пересоздавать уже существующие на роутере (не наших) объекты.
|
|
*/
|
|
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(),
|
|
policyTemplateGroup: i["policy-template-group"],
|
|
comment: (i.comment ?? "").trim(),
|
|
disabled: asBool(i.disabled),
|
|
})),
|
|
modeConfigs: state.modeConfigs
|
|
.filter(isManagedIpsecModeConfig)
|
|
.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(),
|
|
outInterface: n["out-interface"],
|
|
outInterfaceList: n["out-interface-list"],
|
|
ipsecPolicy: n["ipsec-policy"],
|
|
comment: (n.comment ?? "").trim(),
|
|
})),
|
|
})
|
|
}
|
|
|
|
export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
|
|
client: MikrotikClient
|
|
peers: IpsecLivePeer[]
|
|
identities: IpsecLiveIdentity[]
|
|
modeConfigs: IpsecLiveModeConfig[]
|
|
pools: IpsecLivePool[]
|
|
nat: IpsecLiveNat[]
|
|
policies: RosIpsecPolicy[]
|
|
certificateNames: string[]
|
|
snapshot: IpsecSnapshot
|
|
}> {
|
|
const state = await fetchIpsecState(server)
|
|
return {
|
|
client: state.client,
|
|
policies: state.policies,
|
|
certificateNames: state.certs.map(c => c.name ?? "").filter(Boolean),
|
|
peers: state.peers
|
|
.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
|
|
.map((i) => ({
|
|
rosId: String(i[".id"] ?? ""),
|
|
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(),
|
|
policyTemplateGroup: i["policy-template-group"],
|
|
comment: (i.comment ?? "").trim(),
|
|
disabled: asBool(i.disabled),
|
|
})),
|
|
modeConfigs: state.modeConfigs
|
|
.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
|
|
.map((p) => ({
|
|
rosId: String(p[".id"] ?? ""),
|
|
name: (p.name ?? "").trim(),
|
|
ranges: (p.ranges ?? "").trim(),
|
|
comment: (p.comment ?? "").trim(),
|
|
})),
|
|
nat: state.nat
|
|
.map((n) => ({
|
|
rosId: String(n[".id"] ?? ""),
|
|
chain: (n.chain ?? "").trim(),
|
|
action: (n.action ?? "").trim(),
|
|
srcAddress: (n["src-address"] ?? "").trim(),
|
|
outInterface: n["out-interface"],
|
|
outInterfaceList: n["out-interface-list"],
|
|
ipsecPolicy: n["ipsec-policy"],
|
|
comment: (n.comment ?? "").trim(),
|
|
})),
|
|
snapshot: await captureIpsecSnapshot(server),
|
|
}
|
|
}
|
|
|
|
export type IpsecListResult = IpsecListResponse
|
|
|
|
export async function listIpsec(opts?: { serverId?: string }, dependencies = {
|
|
findServer: async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
|
|
enabledServers: async () => db.select().from(servers).where(eq(servers.enabled, true)),
|
|
readState: fetchIpsecState,
|
|
}): Promise<IpsecListResult> {
|
|
let serverRows: ServerRow[]
|
|
if (opts?.serverId) {
|
|
const id = parseDbServerId(opts.serverId)
|
|
if (id === null) {
|
|
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
|
|
}
|
|
const row = await dependencies.findServer(id)
|
|
serverRows = row ? [row] : []
|
|
} else {
|
|
serverRows = await dependencies.enabledServers()
|
|
}
|
|
|
|
const failures: IpsecListResult["failures"] = []
|
|
const summaries: IpsecServerSummaryDto[] = []
|
|
const clients: IpsecClientDto[] = []
|
|
await Promise.all(
|
|
serverRows.map(async (server) => {
|
|
try {
|
|
if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
|
|
const state = await dependencies.readState(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|undefined>{return boundedCompleteCount(()=>listIpsec(),value=>completeCollectionCount(value,"clients"))}
|
|
|
|
export async function getEnabledIpsecServerById(serverId: string | number, lookup = async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]) {
|
|
const id = parseDbServerId(serverId)
|
|
if (id === null) return null
|
|
const row = await lookup(id)
|
|
return row?.enabled ? row : 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 }
|