Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <[email protected]>
619 lines
23 KiB
TypeScript
619 lines
23 KiB
TypeScript
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,
|
|
resolveIke2CaName,
|
|
resolveIke2ServerCert,
|
|
selectIke2Peers,
|
|
selectSharedIke2Identity,
|
|
} from "./ipsec-config.js"
|
|
import { certificateRole, type CertificateRoleContext } from "./certificate-parse.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, 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: 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 }
|
|
}
|
|
|
|
/**
|
|
* Клиенты 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()).filter(Boolean),
|
|
identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
|
}
|
|
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
|
|
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
|
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
|
|
|
|
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)
|
|
}
|
|
|
|
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[] = []
|
|
|
|
// 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 active = (cn ? activeByRemote.get(cn) : undefined)
|
|
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
|
|
out.push({
|
|
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
|
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
|
|
serverId: String(server.id),
|
|
serverName,
|
|
name: 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?.address || undefined,
|
|
activeSince: active?.established || undefined,
|
|
disabled: asBool(identity?.disabled),
|
|
comment: comment || undefined,
|
|
managed: isUser || isIpsecManagedComment(comment),
|
|
})
|
|
}
|
|
|
|
// 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 ? activeByRemote.get(remoteId) : undefined
|
|
out.push({
|
|
id: `${server.id}:${rosId || "identity"}`,
|
|
rosId: rosId || "identity",
|
|
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?.address || undefined,
|
|
activeSince: active?.established || undefined,
|
|
disabled: asBool(i.disabled),
|
|
comment: i.comment || undefined,
|
|
managed,
|
|
})
|
|
}
|
|
|
|
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
|
|
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)
|
|
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()).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,
|
|
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(),
|
|
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 }
|