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 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 { if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся") const client = reader ?? MikrotikClient.fromServer(server) const collection = async (path: string): Promise => { const raw = await client.get(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("/ip/ipsec/peer"), collection("/ip/ipsec/identity"), collection("/ip/ipsec/mode-config"), collection("/ip/pool"), collection("/ip/ipsec/policy"), collection("/ip/firewall/nat"), collection("/ip/ipsec/active-peers"), collection("/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() const ambiguousRemoteIds = new Set() 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() const cnCounts = new Map() 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() 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() 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([ ...(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 { 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 { 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{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((_, 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 }