diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 22926956..958e606a 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -1,7 +1,8 @@ "use client" import { z } from "zod" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useSearchParams } from "next/navigation" import { PageHeader } from "@/components/page-header" import { servers as mockServers } from "@/lib/data" import type { Server } from "@/lib/data" @@ -174,7 +175,7 @@ const ipsecInventorySchema = z.array(z.object({ latency: z.number().nullable().optional(), asn: z.string().optional(), })).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) -function IpsecPage() { +function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWorkspaceTab }: { selectedServerId: string; setSelectedServerId: (id: string) => void; workspaceTab: IpsecWorkspaceTab; setWorkspaceTab: (tab: IpsecWorkspaceTab) => void }) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" @@ -201,10 +202,9 @@ function IpsecPage() { const certPending = useRef(false) useEffect(() => () => { certEpoch.current += 1 }, []) - const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) - const [workspaceTab, setWorkspaceTab] = useState("clients") const [search, setSearch] = useState("") const [initOpen, setInitOpen] = useState(false) + const [initTargetId, setInitTargetId] = useState() const [userOpen, setUserOpen] = useState(false) const [editing, setEditing] = useState(null) const [certOpen, setCertOpen] = useState(false) @@ -265,10 +265,8 @@ function IpsecPage() { const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS - const effectiveServerId = - selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) - ? selectedServerId - : ALL_SERVERS_ID + const effectiveServerId = selectedServerId + const missingSelectedServer = selectedServerId !== ALL_SERVERS_ID && hasLoaded && !displayServers.some(s => s.id === selectedServerId) const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId @@ -315,7 +313,7 @@ function IpsecPage() { } const failedServerIds = new Set(failures.map(f => f.serverId)) - const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId))) + const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId))) const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError && !loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId) @@ -337,7 +335,7 @@ function IpsecPage() { operationLock.current = false if (mounted.current) setBusy(false) } - const hasReadyDevice = displayServers.some(s => canManage(s.id)) + const hasReadyDevice = !missingSelectedServer && displayServers.some(s => (effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId) && canManage(s.id)) const scopedClients = useMemo(() => { if (effectiveServerId === ALL_SERVERS_ID) return displayClients @@ -366,10 +364,6 @@ function IpsecPage() { const caOk = scopedSummaries.some((s) => s.caCert) const compactServer = effectiveServerId !== ALL_SERVERS_ID const sheetServerId = compactServer ? effectiveServerId : undefined - const certNames = useMemo( - () => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(), - [scopedSummaries], - ) const peerOptions = useMemo( () => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))), [scopedSummaries], @@ -583,7 +577,6 @@ function IpsecPage() { address: form.address.trim() || undefined, exchangeMode: form.exchangeMode.trim() || undefined, passive: form.passive, - certificate: form.certificate.trim() || undefined, profile: form.profile.trim() || undefined, disabled: form.disabled, }) @@ -626,6 +619,7 @@ function IpsecPage() { <>
+ {missingSelectedServer && Устройство из ссылки не найденоВыберите доступный сервер. Данные других устройств не подставляются в этот срез.} Состояние серверов IPsec пока неизвестно

: { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined} + onInit={isLive ? (s) => { setInitTargetId(s.serverId); setSelectedServerId(s.serverId); setInitOpen(true) } : undefined} onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined} onEditPeer={isLive ? openEditPeer : undefined} onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined} @@ -831,12 +826,10 @@ function IpsecPage() { lines: [ "/ip ipsec peer add \\", " name=ipsec-vpn address=0.0.0.0/0 \\", - " exchange-mode=ike2 passive=yes \\", - " certificate=ipsec-server send-cert=always", + " exchange-mode=ike2 passive=yes profile=ipsec-vpn", "/ip ipsec identity add \\", - " peer=ipsec-vpn auth-method=rsa-key \\", + " peer=ipsec-vpn auth-method=digital-signature \\", " certificate=ipsec-server \\", - " match-by=certificate \\", " generate-policy=port-strict \\", " mode-config=ipsec-vpn", ], @@ -892,7 +885,6 @@ function IpsecPage() { onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }} busy={busy} editing={editingPeer?.peer ?? null} - certificates={certNames} onSubmit={handleEditPeer} /> @@ -901,7 +893,7 @@ function IpsecPage() { onOpenChange={setInitOpen} servers={serverOptions.filter(s => canManage(s.id) || busy)} busy={busy} - defaultServerId={sheetServerId} + defaultServerId={initTargetId ?? sheetServerId} onSubmit={handleInit} /> @@ -959,7 +951,21 @@ function IpsecPage() { ) } -export default function IpsecSource() { +function IpsecRoute() { const { mode, backendUrl } = useDataSource() - return + const params = useSearchParams() + const selectedServerId = params.get("server") ?? ALL_SERVERS_ID + const rawTab = params.get("tab") + const workspaceTab: IpsecWorkspaceTab = rawTab === "server" || rawTab === "cli" ? rawTab : "clients" + const navigate = (key: string, value: string, defaultValue: string) => { + const next = new URLSearchParams(params.toString()) + if (value === defaultValue) next.delete(key) + else next.set(key, value) + window.history.pushState(null, "", "/ipsec" + (next.size ? "?" + next.toString() : "")) + } + return navigate("server", id, ALL_SERVERS_ID)} workspaceTab={workspaceTab} setWorkspaceTab={tab => navigate("tab", tab, "clients")} /> +} + +export default function IpsecSource() { + return Загрузка IPsec…
}> } diff --git a/backend/package.json b/backend/package.json index 60dbbe29..b2ad9022 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,8 +28,9 @@ "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", + "test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", - "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database", + "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database", "test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", "test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts", "test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts", diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index 2877a335..e3cca7ba 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -14,7 +14,6 @@ import { } from "@mmapp/contracts/ipsec" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { - IPSEC_CA_CERT, IPSEC_COMMON_NAME, IPSEC_SERVER_CERT, buildClientInstructions, @@ -49,7 +48,6 @@ import { patchPeer, deletePeer, ensureSharedIke2Identity, - listByPath, } from "../services/ipsec-ros.js" import { ensureCaCertificate, @@ -65,6 +63,7 @@ import { getEnabledIpsecServerById, listIpsec, mapClients, + resolveIpsecClientTarget, mapServerSummary, } from "../services/ipsec-live.js" import { @@ -75,11 +74,12 @@ import { } from "../services/config-revisions.js" import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js" import { executeRosOps } from "../services/ros-ops.js" +import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js" import { parseDbServerId } from "../utils/server-id.js" +import { isIP } from "node:net" import { randomBytes } from "node:crypto" import { z } from "zod" -const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/ function generatePassphrase(): string { return randomBytes(9).toString("base64url") @@ -174,7 +174,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { const sid = parseDbServerId(q.serverId) if (sid !== null) { const server = await getEnabledIpsecServerById(sid) - if (server) await recordIpsec(server, "observed") + if (server && !result.failures?.some(f => f.serverId === String(sid))) await recordIpsec(server, "observed") } return reply.send(result) }) @@ -206,6 +206,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment) await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment) await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment) + await ensureSharedIke2Identity(client, { peerName: IPSEC_COMMON_NAME, serverCertName: serverCert, modeConfigName: IPSEC_COMMON_NAME, comment: ipsecManagedComment("IKEv2 listener") }) if (body.createNatRule) { await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN")) } @@ -225,31 +226,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { if (!server) return reply.status(404).send({ error: "Сервер не найден" }) const client = MikrotikClient.fromServer(server) try { - for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) { - const rows = await listByPath(client, path) - for (const row of rows) { - if (!row[".id"]) continue - if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) { - await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined) - } - } - } - const nat = await listByPath(client, "/ip/firewall/nat") - for (const row of nat) { - if (row[".id"] && isIpsecManagedComment(row.comment)) { - await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined) - } - } - // profile/proposal не критично оставлять; сертификаты — по флагу - if (removeCertificates) { - const certs = await client.getCertificates() - for (const c of certs) { - const name = String(c.name ?? "") - if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) { - await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined) - } - } - } + await removeManagedIpsecServer(client, removeCertificates) await recordIpsec(server, "apply") return reply.send({ ok: true }) } catch (e) { @@ -275,7 +252,6 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { ?? ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) ?? ike2Peers[0] - ?? state.peers[0] if (!peer) { return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" }) } @@ -288,13 +264,15 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { .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) - const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) + const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities) const caName = resolveIke2CaName(state.certs, serverCertRow) if (body.authMethod === "certificate" && (!caName || !serverCertRow)) { return reply.status(400).send({ error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK", }) } + if (body.peerName && (peer.name ?? "").trim() !== body.peerName.trim()) return reply.status(404).send({ error: "Выбранный peer не найден" }) + if (mapClients(state).some(c => c.name === body.name.trim()) || (body.authMethod === "certificate" && state.certs.some(c => c.name === clientCertName(body.name)))) return reply.status(409).send({ error: "Клиент уже существует" }) const peerName = (peer.name ?? "").trim() const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host const dns = sharedMc?.["static-dns"]?.trim() || undefined @@ -309,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { let modeConfig = (sharedMc?.name ?? "").trim() if (body.staticIp) { const ip = body.staticIp.trim() - if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) + if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) const taken = takenStaticIps(state.modeConfigs) if (taken.includes(ip)) { return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) @@ -386,22 +364,25 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) } const d = parsed.data + if ("staticIp" in d && d.staticIp && isIP(d.staticIp.trim()) !== 4) return reply.status(400).send({ error: "Некорректный статический IPv4 адрес" }) const server = await getEnabledIpsecServerById(serverIdParam(serverId)) if (!server) return reply.status(404).send({ error: "Сервер не найден" }) const client = MikrotikClient.fromServer(server) try { const state = await fetchIpsecState(server) const targetRosId = rosIdParam(rosId) - const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId) - const certRow = identity - ? undefined - : state.certs.find((c) => String(c[".id"] ?? "") === targetRosId) - ?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId) + const target = resolveIpsecClientTarget(state, targetRosId) + if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" }) + let { identity, certRow } = target + if (certRow) { + identity = state.identities.find(i => i["remote-certificate"] === certRow?.name) + if (identity) certRow = undefined + } if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" }) const ike2Peers = selectIke2Peers(state.peers) const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) - const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) + const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities) const managedIdentity = identity ? isIpsecManagedComment(identity.comment) : String(certRow?.name ?? "").startsWith("ipsec-user-") @@ -419,9 +400,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(), }) } - // managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем) - if (certRow?.[".id"] && managedIdentity) { - await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) }) + // Preserve the signed certificate CN/key/name; keep the editable display name in an identity. + if (certRow && !d.staticIp) { + const peer = ike2Peers.find(p => isIpsecManagedComment(p.comment)) ?? ike2Peers[0] + if (!peer || !serverCertRow) return reply.status(400).send({ error: "Нет IKEv2 peer или серверного сертификата" }) + await putIdentity(client, { peerName: peer.name ?? "", modeConfig: sharedMcName, comment: ipsecUserComment(d.name), authMethod: "certificate", certificate: serverCertRow.name, remoteCertificate: certRow.name }) } } @@ -434,7 +417,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { if (personal) await deleteUserModeConfig(client, personal) } else { const ip = d.staticIp.trim() - if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) + if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) const others = takenStaticIps( state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc), ) @@ -446,7 +429,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { } else if (!identity && certRow && d.staticIp) { // cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config const ip = d.staticIp.trim() - if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) + if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` }) const taken = takenStaticIps(state.modeConfigs) if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0] @@ -491,11 +474,10 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { try { const state = await fetchIpsecState(server) const targetRosId = rosIdParam(rosId) - const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId) - const certRow = identity - ? undefined - : state.certs.find((c) => String(c[".id"] ?? "") === targetRosId) - ?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId) + const target = resolveIpsecClientTarget(state, targetRosId) + if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" }) + const identity = target.identity ?? (target.certRow ? state.identities.find(i => i["remote-certificate"] === target.certRow?.name) : undefined) + const certRow = identity ? undefined : target.certRow if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" }) const managed = identity ? isIpsecManagedComment(identity.comment) @@ -514,11 +496,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { if (removeCertificate) { // удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем const ike2Peers = selectIke2Peers(state.peers) - const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers)) - const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim() + const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities)) + const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers, state.identities)?.name ?? "").trim() const certName = remoteCert || (managed && userName ? clientCertName(userName) : "") if (certName && certName !== caName && certName !== serverCertName) { - await client.removeCertificate(certName).catch(() => undefined) + await client.removeCertificate(certName) } } await recordIpsec(server, "apply") @@ -547,7 +529,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { if (d.address !== undefined) body.address = d.address if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no" - if (d.certificate !== undefined) body.certificate = d.certificate + if (d.certificate !== undefined) return reply.status(400).send({ error: "Сертификат задаётся в IPsec identity, а не в peer" }) if (d.profile !== undefined) body.profile = d.profile if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no" if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body) @@ -633,7 +615,9 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => { const client = MikrotikClient.fromServer(server) try { const state = await fetchIpsecState(server) - const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId)) + const target = resolveIpsecClientTarget(state, rosIdParam(rosId)) + if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" }) + const identity = target.identity if (!identity) return reply.status(404).send({ error: "Клиент не найден" }) const userName = parseIpsecUserComment(identity.comment) ?? "" if (!userName) return reply.status(400).send({ error: "Не managed-клиент" }) diff --git a/backend/src/services/entity-snapshots.ts b/backend/src/services/entity-snapshots.ts index 1c3ae9f4..485572ba 100644 --- a/backend/src/services/entity-snapshots.ts +++ b/backend/src/services/entity-snapshots.ts @@ -693,8 +693,6 @@ function ipsecPeerBody(p: IpsecSnapshotPeer): Record { 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), @@ -723,7 +721,6 @@ function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record "address-pool": m.addressPool, address: m.address, "static-dns": m.staticDns, - comment: m.comment, }) } diff --git a/backend/src/services/ipsec-ca.ts b/backend/src/services/ipsec-ca.ts index 0e627e26..115f374c 100644 --- a/backend/src/services/ipsec-ca.ts +++ b/backend/src/services/ipsec-ca.ts @@ -48,6 +48,8 @@ export async function ensureCaCertificate(client: MikrotikClient, daysValid: num await client.signCertificate({ name: IPSEC_CA_CERT, daysValid }) await waitCertificateSigned(client, IPSEC_CA_CERT) } + const ca = await findCertificate(client, IPSEC_CA_CERT) + if (ca?.[".id"] && !["true", "yes"].includes(ca.trusted ?? "")) await client.patch(`/certificate/${encodeURIComponent(ca[".id"])}`, { trusted: "yes" }) return IPSEC_CA_CERT } diff --git a/backend/src/services/ipsec-cleanup.ts b/backend/src/services/ipsec-cleanup.ts new file mode 100644 index 00000000..69ebeae0 --- /dev/null +++ b/backend/src/services/ipsec-cleanup.ts @@ -0,0 +1,32 @@ +import type { MikrotikClient } from "./mikrotik.js" +import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, isManagedIpsecModeConfig, isIpsecManagedComment } from "./ipsec-config.js" +import { listByPath } from "./ipsec-ros.js" + +/** Read the whole deletion scope before writing; never treat unreadable state as empty. */ +export async function removeManagedIpsecServer(client: MikrotikClient, removeCertificates: boolean): Promise { + const paths = ["/ip/ipsec/identity", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/ipsec/mode-config", "/ip/pool", "/ip/firewall/nat"] + if (removeCertificates) paths.push("/certificate") + const snapshots = await Promise.all(paths.map(async path => ({ path, rows: await listByPath(client, path) }))) + const owned = (path: string, row: Record) => isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/mode-config" && isManagedIpsecModeConfig(row)) || (path === "/certificate" && (row.name === IPSEC_CA_CERT || row.name === IPSEC_SERVER_CERT || row.name?.startsWith("ipsec-user-"))) + const names = (path: string) => new Set(snapshots.find(s => s.path === path)?.rows.filter(row => owned(path, row)).map(row => row.name).filter((name): name is string => typeof name === "string" && name.length > 0)) + const peerNames = names("/ip/ipsec/peer"), modeConfigs = names("/ip/ipsec/mode-config"), certificates = names("/certificate") + for (const { path, rows } of snapshots) { + for (const row of rows.filter(row => !owned(path, row))) { + if (path === "/certificate" && certificates.has(row.ca ?? "")) throw new Error("Удаление заблокировано: управляемый CA подписал чужой сертификат") + if (path === "/ip/ipsec/identity" && (peerNames.has(row.peer ?? "") || modeConfigs.has(row["mode-config"] ?? "") || certificates.has(row.certificate ?? "") || certificates.has(row["remote-certificate"] ?? ""))) throw new Error("Удаление заблокировано: объекты IPsec используются чужой identity") + if (path === "/ip/ipsec/policy" && row.dynamic !== "true" && row.dynamic !== "yes" && (row.peer ?? "").split(",").some(peer => peerNames.has(peer.trim()))) throw new Error("Удаление заблокировано: peer используется чужой policy") + } + } + for (const { path, rows } of snapshots) { + if (rows.some(row => owned(path, row) && !row[".id"])) throw new Error(`Нет ID управляемого объекта: ${path}`) + } + for (const { path, rows } of snapshots) { + // Default/foreign templates are preserved. Certificates use the existing reserved naming convention. + const targets = rows.filter(row => owned(path, row)) + if (path === "/certificate") targets.sort((a, b) => Number(a.name === IPSEC_CA_CERT) - Number(b.name === IPSEC_CA_CERT)) + for (const row of targets) { + if (!row[".id"]) throw new Error(`Нет ID управляемого объекта: ${path}`) + await client.delete(`${path}/${encodeURIComponent(row[".id"])}`) + } + } +} diff --git a/backend/src/services/ipsec-config.test.ts b/backend/src/services/ipsec-config.test.ts index b7e70202..95b3a89d 100644 --- a/backend/src/services/ipsec-config.test.ts +++ b/backend/src/services/ipsec-config.test.ts @@ -36,6 +36,8 @@ import { identityRosBody } from "./ipsec-ros.js" assert.ok(!isIpsecManagedComment(undefined)) assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`) assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice") + assert.equal(parseIpsecUserComment(ipsecUserComment("Alice Cooper")), "Alice Cooper") + assert.equal(parseIpsecUserComment("foreign user=alice"), null) assert.equal(parseIpsecUserComment("не managed"), null) assert.equal(parseIpsecUserComment(null), null) } @@ -187,7 +189,8 @@ import { identityRosBody } from "./ipsec-ros.js" remoteCertificate: "ipsec-user-alice", placeBefore: "*S", }) - assert.equal(body["place-before"], "*S") + assert.equal(body["auth-method"], "digital-signature") + assert.equal(body["place-before"], undefined) assert.equal(body["remote-certificate"], "ipsec-user-alice") assert.equal(body["match-by"], "certificate") } diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts index 9240f015..397baa9f 100644 --- a/backend/src/services/ipsec-config.ts +++ b/backend/src/services/ipsec-config.ts @@ -11,7 +11,12 @@ export function ipsecManagedComment(label = ""): string { } export function isIpsecManagedComment(comment: string | undefined | null): boolean { - return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER) + return Boolean(comment) && (String(comment).trim() === IPSEC_MARKER || String(comment).trim().startsWith(`${IPSEC_MARKER} `)) +} + +/** Reserved RouterOS names identify mode-configs on versions without a comment property. */ +export function isManagedIpsecModeConfig(row: { name?: string; comment?: string }): boolean { + return isIpsecManagedComment(row.comment) || row.name === "ipsec-vpn" || Boolean(row.name?.startsWith("mc-ipsec-")) } /** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */ @@ -20,8 +25,8 @@ export function ipsecUserComment(name: string): string { } export function parseIpsecUserComment(comment: string | undefined | null): string | null { - if (!comment) return null - const m = /user=([^\s]+)/.exec(comment.trim()) + if (!isIpsecManagedComment(comment)) return null + const m = /(?:^|\s)user=(.+)$/.exec(comment!.trim()) return m?.[1] ?? null } @@ -150,7 +155,7 @@ export function isIke2RemoteAccessIdentity( if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false const hasModeConfig = Boolean((i["mode-config"] ?? "").trim()) const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim()) - const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key" + const rsaKey = ["rsa-key", "digital-signature"].includes((i["auth-method"] ?? "").trim().toLowerCase()) return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim())) } @@ -176,8 +181,9 @@ export function selectSharedIke2Identity( } /** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */ -export function resolveIke2ServerCert(certs: T[], ike2Peers: PeerLike[]): T | undefined { - const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean)) +export function resolveIke2ServerCert(certs: T[], ike2Peers: PeerLike[], identities: Ike2IdentityLike[] = []): T | undefined { + const peerNames = new Set(ike2Peers.map(p => p.name)) + const names = new Set([...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...identities.filter(i => peerNames.has(i.peer)).map(i => (i.certificate ?? "").trim())].filter(Boolean)) return certs.find((c) => names.has(String(c.name ?? "").trim())) ?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT) ?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) @@ -297,7 +303,7 @@ export function buildClientInstructions(args: { `1. Скачайте и импортируйте сертификат ${args.p12Filename}.`, pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.", "2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»", - " (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).", + " Импортируйте .p12 в «Локальный компьютер» → «Личный», CA — в доверенные корневые сертификаты.", "3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),", " аутентификация — сертификат; удалённый ID = CN серверного сертификата.", "4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).", diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index c3d247b2..c721af3a 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -1,4 +1,5 @@ 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" @@ -20,6 +21,7 @@ import { identityDisplayName, isIke2RemoteAccessIdentity, isIpsecManagedComment, + isManagedIpsecModeConfig, resolveIke2CaName, resolveIke2ServerCert, selectIke2Peers, @@ -196,18 +198,19 @@ export interface IpsecServerState { certs: RosCertRow[] } -export async function fetchIpsecState(server: ServerRow): Promise { - const client = MikrotikClient.fromServer(server) - const empty = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) +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([ - client.get("/ip/ipsec/peer").then((v) => empty(v)).catch(() => [] as RosIpsecPeer[]), - client.get("/ip/ipsec/identity").then((v) => empty(v)).catch(() => [] as RosIpsecIdentity[]), - client.get("/ip/ipsec/mode-config").then((v) => empty(v)).catch(() => [] as RosIpsecModeConfig[]), - client.get("/ip/pool").then((v) => empty(v)).catch(() => [] as RosIpsecPool[]), - client.get("/ip/ipsec/policy").then((v) => empty(v)).catch(() => [] as RosIpsecPolicy[]), - client.get("/ip/firewall/nat").then((v) => empty(v)).catch(() => [] as RosFirewallNat[]), - client.get("/ip/ipsec/active-peers").then((v) => empty(v)).catch(() => [] as RosIpsecActivePeer[]), - client.getCertificates().catch(() => [] as RosCertRow[]), + 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 } } @@ -226,10 +229,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id) const roleCtx: CertificateRoleContext = { - peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean), + 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)) + 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 @@ -270,11 +273,11 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { 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), + 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: cn || certName, + name: identity ? identityDisplayName(identity, state.certs) : (cn || certName), authMethod: "certificate", kind: "cert", certificateName: certName, @@ -305,8 +308,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { const remoteId = (i["remote-id"] ?? "").trim() const active = remoteId ? activeByRemote.get(remoteId) : undefined out.push({ - id: `${server.id}:${rosId || "identity"}`, - rosId: rosId || "identity", + id: `${server.id}:identity:${rosId}`, + rosId: `identity:${rosId}`, serverId: String(server.id), serverName, name: identityDisplayName(i, state.certs), @@ -328,6 +331,16 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { 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) @@ -353,12 +366,12 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt ?? state.pools.find((p) => isIpsecManagedComment(p.comment)) : undefined - const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) + 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()).filter(Boolean), + 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([ @@ -432,7 +445,7 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise isIpsecManagedComment(m.comment)) + .filter(isManagedIpsecModeConfig) .map((m) => ({ name: (m.name ?? "").trim(), addressPool: (m["address-pool"] ?? "").trim(), @@ -496,7 +509,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ .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, + authMethod: (i["auth-method"] ?? "").trim(), certificate: (i.certificate ?? "").trim(), remoteCertificate: (i["remote-certificate"] ?? "").trim(), matchBy: (i["match-by"] ?? "").trim(), @@ -508,7 +521,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ disabled: asBool(i.disabled), })), modeConfigs: state.modeConfigs - .filter((m) => isIpsecManagedComment(m.comment)) + .filter(isManagedIpsecModeConfig) .map((m) => ({ rosId: String(m[".id"] ?? ""), name: (m.name ?? "").trim(), @@ -540,17 +553,21 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{ export type IpsecListResult = IpsecListResponse -export async function listIpsec(opts?: { serverId?: string }): Promise { +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 = Number.parseInt(String(opts.serverId), 10) - if (!Number.isFinite(id)) { + const id = parseDbServerId(opts.serverId) + if (id === null) { 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] + const row = await dependencies.findServer(id) serverRows = row ? [row] : [] } else { - serverRows = await db.select().from(servers).where(eq(servers.enabled, true)) + serverRows = await dependencies.enabledServers() } const failures: IpsecListResult["failures"] = [] @@ -559,7 +576,8 @@ export async function listIpsec(opts?: { serverId?: string }): Promise { try { - const state = await fetchIpsecState(server) + 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) @@ -579,10 +597,11 @@ export async function listIpsec(opts?: { serverId?: string }): Promise{return boundedCompleteCount(()=>listIpsec(),value=>completeCollectionCount(value,"clients"))} -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 +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 сертификата). */ diff --git a/backend/src/services/ipsec-ros.ts b/backend/src/services/ipsec-ros.ts index c25ab693..b11e18a7 100644 --- a/backend/src/services/ipsec-ros.ts +++ b/backend/src/services/ipsec-ros.ts @@ -1,6 +1,7 @@ import type { MikrotikClient } from "./mikrotik.js" import { ipsecManagedComment, + isManagedIpsecModeConfig, selectIke2Peers, selectSharedIke2Identity, type Ike2IdentityLike, @@ -21,8 +22,9 @@ export function toRosBody(obj: Record async function listByPath(client: MikrotikClient, path: string): Promise { - const raw = await client.get(path).catch(() => []) - return Array.isArray(raw) ? (raw as RosRow[]) : [] + 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 RosRow[] } /** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */ @@ -49,21 +51,20 @@ async function findRosId(client: MikrotikClient, path: string, name: string): Pr // ── инициализация IKEv2-сервера ───────────────────────────────────────────── -export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise { +export async function ensureIpsecProfile(client: MikrotikClient, name: string, _comment: string): Promise { 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", + "dh-group": "modp2048", "nat-traversal": "yes", - comment, })) } export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise { await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({ name, - "auth-algorithms": "sha256,sha1", + "auth-algorithms": "sha256", "enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc", "pfs-group": "modp2048", comment, @@ -80,13 +81,13 @@ export async function ensureSharedModeConfig( name: string, poolName: string, dns: string | undefined, - comment: string, + _comment: string, ): Promise { await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({ name, "address-pool": poolName, + responder: "yes", "static-dns": dns, - comment, })) } @@ -119,7 +120,7 @@ export async function ensurePolicyTemplate( export async function ensureIpsecPeer( client: MikrotikClient, name: string, - serverCertName: string, + _serverCertName: string, profileName: string, comment: string, ): Promise { @@ -128,8 +129,6 @@ export async function ensureIpsecPeer( address: "0.0.0.0/0", "exchange-mode": "ike2", passive: "yes", - certificate: serverCertName, - "send-cert": "always", profile: profileName, comment, })) @@ -157,18 +156,21 @@ export async function putUserModeConfig( client: MikrotikClient, name: string, staticIp: string, - comment: string, + _comment: string, ): Promise { await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({ name, address: staticIp, - comment, + responder: "yes", + "address-prefix-length": "32", })) } export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise { - const id = await findRosId(client, "/ip/ipsec/mode-config", name) - if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`) + const row = (await listByPath(client, "/ip/ipsec/mode-config")).find(row => row.name === name) + if (!row?.[".id"] || !isManagedIpsecModeConfig(row)) return + if ((await listByPath(client, "/ip/ipsec/identity")).some(i => i["mode-config"] === name)) return + await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(row[".id"])}`) } export interface IdentityFields { @@ -183,22 +185,21 @@ export interface IdentityFields { /** psk. */ secret?: string remoteId?: string - /** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */ + /** Legacy hint: RouterOS identities do not accept place-before; specificity uses match-by/remote-certificate. */ placeBefore?: string } export function identityRosBody(f: IdentityFields): Record { return toRosBody({ peer: f.peerName, - "auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key", + "auth-method": f.authMethod === "certificate" ? "digital-signature" : "pre-shared-key", certificate: f.certificate, "remote-certificate": f.remoteCertificate, - "match-by": f.authMethod === "certificate" ? "certificate" : undefined, + "match-by": f.authMethod === "certificate" && f.remoteCertificate ? "certificate" : undefined, secret: f.secret, - "remote-id": f.remoteId, + "remote-id": f.remoteId && !f.remoteId.includes(":") && !["auto", "ignore"].includes(f.remoteId) ? `key-id:${f.remoteId}` : f.remoteId, "mode-config": f.modeConfig, "generate-policy": "port-strict", - "place-before": f.placeBefore, comment: f.comment, }) } diff --git a/backend/src/services/ipsec-state.test.ts b/backend/src/services/ipsec-state.test.ts new file mode 100644 index 00000000..9183d694 --- /dev/null +++ b/backend/src/services/ipsec-state.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict" +import { fetchIpsecState, getEnabledIpsecServerById, listIpsec, mapClients, mapServerSummary, resolveIpsecClientTarget } from "./ipsec-live.js" +import { removeManagedIpsecServer } from "./ipsec-cleanup.js" +import { deleteUserModeConfig, ensureIpsecPeer, identityRosBody, putOrPatchByName } from "./ipsec-ros.js" +import { ipsecManagedComment, isIpsecManagedComment } from "./ipsec-config.js" +import type { MikrotikClient } from "./mikrotik.js" + +const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters[0] +const paths = ["/ip/ipsec/peer", "/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/pool", "/ip/ipsec/policy", "/ip/firewall/nat", "/ip/ipsec/active-peers", "/certificate"] +let checks = 0 +for (const path of paths) { + const reader = { get: async (p: string) => { if (p === path) throw Error("upstream failed"); return [] } } as unknown as MikrotikClient + await assert.rejects(fetchIpsecState(server, reader), /upstream failed/); checks++ + for (const invalid of [{}, null, [null], [{}]]) { + await assert.rejects(fetchIpsecState(server, { get: async (p: string) => p === path ? invalid : [] } as unknown as MikrotikClient), /Некорректный ответ/); checks++ + } +} +let reads = 0 +const reader = { get: async () => { reads++; return [] } } as unknown as MikrotikClient +await assert.rejects(fetchIpsecState({ ...server, enabled: false }, reader), /выключен/) +assert.equal(reads, 0); checks++ +for (const id of ["1oops", "-1", "0", "srv1"]) { + await assert.equal(await getEnabledIpsecServerById(id, async () => { throw Error("must not look up") }), null); checks++ +} +assert.equal(await getEnabledIpsecServerById(1, async () => ({ ...server, enabled: false })), null); checks++ +const dependencies = { findServer: async () => server, enabledServers: async () => [server, { ...server, id: 2 }], readState: async (s: typeof server) => { if (s.id === 2) throw Error("second failed"); return fetchIpsecState(s, reader) } } +const partial = await listIpsec(undefined, dependencies) +assert.equal(partial.servers.length, 1); assert.equal(partial.failures?.[0]?.serverId, "2"); checks++ +reads = 0 +const disabled = await listIpsec({ serverId: "1" }, { ...dependencies, findServer: async () => ({ ...server, enabled: false }) }) +assert.equal(disabled.servers.length, 0); assert.match(disabled.failures?.[0]?.error ?? "", /выключен/); assert.equal(reads, 0); checks++ +const state = await fetchIpsecState(server, { get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? [{ ".id": "*I", peer: "ike", "auth-method": "digital-signature", certificate: "server", "mode-config": "mc" }] : p === "/certificate" ? [{ ".id": "*CS", name: "server", ca: "ca", "key-usage": "tls-server,tls-client" }, { ".id": "*CA", name: "ca", "key-usage": "key-cert-sign" }] : [] } as unknown as MikrotikClient) +assert.equal(mapClients(state).length, 0); assert.equal(mapServerSummary(state, []).serverCert?.role, "server"); checks++ +assert.equal(identityRosBody({ peerName: "ike", modeConfig: "mc", comment: "test", authMethod: "certificate", certificate: "server" })["auth-method"], "digital-signature") +assert.equal(identityRosBody({ peerName: "ike", modeConfig: "mc", comment: "test", authMethod: "pre-shared-key", remoteId: "alice" })["remote-id"], "key-id:alice"); checks++ +assert.equal(isIpsecManagedComment("MikrotikManager:ipsec-foreign"), false); checks++ +const writes: Array<{ path: string; body?: Record }> = [] +await ensureIpsecPeer({ get: async () => [], put: async (path: string, body: Record) => writes.push({ path, body }) } as unknown as MikrotikClient, "ike", "server", "profile", "test") +assert.equal(writes[0]?.body?.certificate, undefined); assert.equal(writes[0]?.body?.["send-cert"], undefined); checks++ +await assert.rejects(putOrPatchByName({ get: async () => { throw Error("read failed") }, put: async () => { throw Error("must not write") } } as unknown as MikrotikClient, "/ip/pool", "x", {}), /read failed/); checks++ +for (const failRead of [false, true]) { + const deletes: string[] = [] + const client = { get: async (p: string) => { + if (failRead && p === "/certificate") throw Error("cert unreadable") + return [{ ".id": "*FOREIGN", template: "true", comment: "foreign" }, { ".id": "*OWN", comment: ipsecManagedComment("test") }] + }, delete: async (p: string) => { deletes.push(p) } } as unknown as MikrotikClient + if (failRead) { await assert.rejects(removeManagedIpsecServer(client, true), /cert unreadable/); assert.deepEqual(deletes, []) } + else { await removeManagedIpsecServer(client, true); assert.equal(deletes.length, 7); assert(deletes.every(p => p.endsWith("*OWN"))) } + checks++ +} +let deletes = 0 +await assert.rejects(removeManagedIpsecServer({ get: async () => [{ ".id": "*OWN", comment: ipsecManagedComment("test") }], delete: async () => { deletes++; throw Error("delete denied") } } as unknown as MikrotikClient, false), /delete denied/) +assert.equal(deletes, 1); checks++ +for (const shared of [false, true]) { + const removed: string[] = [] + await deleteUserModeConfig({ get: async (p: string) => p.endsWith("mode-config") ? [{ ".id": "*M", name: "mc", comment: shared ? ipsecManagedComment("test") : "foreign" }] : [{ ".id": "*I", "mode-config": "mc" }], delete: async (p: string) => { removed.push(p) } } as unknown as MikrotikClient, "mc") + assert.deepEqual(removed, []); checks++ +} +for (const reference of ["identity", "policy"]) { + let writes = 0 + await assert.rejects(removeManagedIpsecServer({ get: async (path: string) => path === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "own", comment: ipsecManagedComment("test") }] : path.endsWith(reference) ? [{ ".id": "*F", peer: "own", comment: "foreign" }] : [], delete: async () => { writes++ } } as unknown as MikrotikClient, false), /чужой/) + assert.equal(writes, 0); checks++ +} +state.certs.push({ ".id": "*CLIENT", name: "client", "common-name": "Original CN", ca: "ca", "key-usage": "tls-client" }) +state.identities.push({ ".id": "*PERSONAL", peer: "ike", "auth-method": "digital-signature", "remote-certificate": "client", certificate: "server", comment: ipsecManagedComment("user=Alice Cooper") }) +assert.equal(mapClients(state)[0]?.name, "Alice Cooper"); assert.equal(mapClients(state)[0]?.commonName, "Original CN"); checks++ +state.certs.push({ ".id": "*COLLISION", name: "another-client", "common-name": "Certificate", ca: "ca", "key-usage": "tls-client" }) +state.identities.push({ ".id": "*COLLISION", peer: "ike", "auth-method": "pre-shared-key", comment: ipsecManagedComment("user=PSK"), "mode-config": "mc" }) +assert.equal(resolveIpsecClientTarget(state, "*COLLISION").ambiguous, true); checks++ +assert.equal(resolveIpsecClientTarget(state, "identity:*COLLISION").identity?.["auth-method"], "pre-shared-key"); checks++ +assert.equal(resolveIpsecClientTarget(state, "cert:*COLLISION").certRow?.name, "another-client"); checks++ +assert.equal(resolveIpsecClientTarget(state, "cert:*CA").certRow, undefined); checks++ +state.identities.find(i => i[".id"] === "*PERSONAL")!.comment = "Existing identity label" +assert.equal(mapClients(state).find(c => c.certName === "client")?.name, "Existing identity label"); checks++ +let foreignCertificateWrites = 0 +await assert.rejects(removeManagedIpsecServer({ get: async (path: string) => path === "/certificate" ? [{ ".id": "*CA", name: "ipsec-ca" }, { ".id": "*FOREIGN", name: "external-client", ca: "ipsec-ca" }] : [], delete: async () => { foreignCertificateWrites++ } } as unknown as MikrotikClient, true), /чужой сертификат/) +assert.equal(foreignCertificateWrites, 0); checks++ +console.log(`ipsec-state: ${checks} checks passed`) diff --git a/components/ipsec/ipsec-peer-sheet.tsx b/components/ipsec/ipsec-peer-sheet.tsx index 651fb34f..71d85c9f 100644 --- a/components/ipsec/ipsec-peer-sheet.tsx +++ b/components/ipsec/ipsec-peer-sheet.tsx @@ -16,7 +16,6 @@ export type IpsecPeerFormState = { address: string exchangeMode: string passive: boolean - certificate: string profile: string disabled: boolean } @@ -26,7 +25,6 @@ export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({ address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, - certificate: "", profile: "", disabled: false, }) @@ -39,14 +37,12 @@ function IpsecPeerSheet({ onOpenChange, busy, editing, - certificates, onSubmit, }: { open: boolean onOpenChange: (v: boolean) => void busy?: boolean editing: IpsecPeerDto | null - certificates: string[] onSubmit: (form: IpsecPeerFormState) => void | Promise }) { const [form, setForm] = useState(defaultIpsecPeerForm) @@ -68,7 +64,6 @@ function IpsecPeerSheet({ address: editing.address ?? "", exchangeMode: editing.exchangeMode ?? "ike2", passive: editing.passive ?? false, - certificate: editing.certificate ?? "", profile: editing.profile ?? "", disabled: editing.disabled, })) @@ -119,18 +114,7 @@ function IpsecPeerSheet({ ))} - - set("certificate", e.target.value)} - /> - - {certificates.map((c) => - +

Сертификат аутентификации задаётся в identity. Эта форма изменяет только peer.

set("profile", e.target.value)} /> diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 92b044cd..7ae29278 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,6 +1,6 @@ # UX/UI: протокол внешней приёмки 12.5 -Статус на2026-10-09: backend/frontend ux-vxlan76, backend healthy; раздел VXLAN76 принят в описанных границах. В12.5.67 исправленный Peer .conf принят как клиентский шаблон: API6/6, browser4/4, server endpoint и placeholders; настоящий RSC roundtrip через UI/native/handshake/Ping принят в12.5.68, .conf с явнымName/отказы400/409/сохранение формы приняты в70; поздний native rejection/подтверждённая компенсация/retry приняты в72; изолированный audit INSERT permission denial принят в74; остальные неизвестные исходы/DB faults открыты. Мобильная ширина WireGuard Sheets принята в12.5.64. Мобильная GRE форма и native сохранение на обоих CHR приняты в12.5.57. WireGuard handshake/короткий Ping/native API/read-only UI приняты в границах12.5.58; 12.5.59 воспроизвёл оставшиеся peers/IP; 12.5.60 развернул исправление и принял обычное удаление own WG интерфейса с peer/IPv4 адресом на обоих CHR. 12.5.62 принял полный WireGuard UI create/peer/disable/enable/Ping/delete на обоих CHR; устойчивое меню развёрнуто. Устойчивость меню GRE при live refresh на обоих CHR принята в12.5.55. GRE peer metadata и реальный lifecycle/API/UI/двусторонний Ping подтверждены в12.5.53. Browser retry/checkpoint на реальном HTTP/PG принят в границах12.5.43–45; Telegram sender responses подменены, реальная доставка не проверена. PG-1/2/3/4/5 приняты в описанных границах, PG-6 integrated для одного isolated instance, PG-7 real JWT/processcounts и browser restore passed. PG-8 real PG/native и browser unknown→history done приняты с fault injection после настоящего COMMIT. ROS-8/9 real API/SQL/native приняты в границах12.5.23; browser pair/duration change и delete приняты в12.5.24. ROS-2 authentication failure принят в12.5.25. PortalSSO, физический TCP disconnect, мультиэкземпляры и остальные ROS/Q/интеграции остаются открытыми. [Паспорт](ux-ui-test-server.md), [журнал](ux-ui-progress.md). VXLAN75: собственная native пара/Ping/catalog/URL/export приняты, create/delete API отсутствует; selected baseline восстановлен. +Статус на2026-10-09: backend/frontend ux-ipsec77, backend healthy; основной блок IPsec77 и VXLAN76 приняты в описанных границах. В12.5.67 исправленный Peer .conf принят как клиентский шаблон: API6/6, browser4/4, server endpoint и placeholders; настоящий RSC roundtrip через UI/native/handshake/Ping принят в12.5.68, .conf с явнымName/отказы400/409/сохранение формы приняты в70; поздний native rejection/подтверждённая компенсация/retry приняты в72; изолированный audit INSERT permission denial принят в74; остальные неизвестные исходы/DB faults открыты. Мобильная ширина WireGuard Sheets принята в12.5.64. Мобильная GRE форма и native сохранение на обоих CHR приняты в12.5.57. WireGuard handshake/короткий Ping/native API/read-only UI приняты в границах12.5.58; 12.5.59 воспроизвёл оставшиеся peers/IP; 12.5.60 развернул исправление и принял обычное удаление own WG интерфейса с peer/IPv4 адресом на обоих CHR. 12.5.62 принял полный WireGuard UI create/peer/disable/enable/Ping/delete на обоих CHR; устойчивое меню развёрнуто. Устойчивость меню GRE при live refresh на обоих CHR принята в12.5.55. GRE peer metadata и реальный lifecycle/API/UI/двусторонний Ping подтверждены в12.5.53. Browser retry/checkpoint на реальном HTTP/PG принят в границах12.5.43–45; Telegram sender responses подменены, реальная доставка не проверена. PG-1/2/3/4/5 приняты в описанных границах, PG-6 integrated для одного isolated instance, PG-7 real JWT/processcounts и browser restore passed. PG-8 real PG/native и browser unknown→history done приняты с fault injection после настоящего COMMIT. ROS-8/9 real API/SQL/native приняты в границах12.5.23; browser pair/duration change и delete приняты в12.5.24. ROS-2 authentication failure принят в12.5.25. PortalSSO, физический TCP disconnect, мультиэкземпляры и остальные ROS/Q/интеграции остаются открытыми. [Паспорт](ux-ui-test-server.md), [журнал](ux-ui-progress.md). VXLAN75: собственная native пара/Ping/catalog/URL/export приняты, create/delete API отсутствует; selected baseline восстановлен. ## Паспорт стенда @@ -681,3 +681,9 @@ flowchart LR ``` Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе. + +## 12.5.77 — основной блок IPsec (2026-10-09) + +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. + +Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index af8f8bc1..37d2d94a 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,12 +1,12 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. ## Актуальная точка продолжения — 2026-10-09 -Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты. @@ -368,3 +368,9 @@ flowchart LR ``` Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе. + +## 12.5.77 — основной блок IPsec (2026-10-09) + +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. + +Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 370433fd..8b62b1d1 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -36,7 +36,45 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–76 и ограничения ниже. Runtime backend/frontend ux-vxlan76. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–77 и ограничения ниже. Runtime backend/frontend ux-ipsec77. | + +## 12.5.77 — IPsec: основной раздел, RouterOS7 и реальная профильная приёмка (2026-10-09) + +Укрупнённый блок включает backend/API/UI, тесты, production Docker build/deploy, собственные реальные объекты на обоих CHR и очистку. Один локальный коммит после блока, без push. Установленные ReUI/Frame/base-nova компоненты сохранены. + +**Достоверность и операции.** Чтение восьми коллекций IPsec больше не скрывает отказ RouterOS или некорректный контейнер/строки/ID пустым успешным списком. Агрегатор сохраняет успешные устройства и сообщает ошибку конкретного устройства; observed history не запускается для неудачного среза. Выключенный сервер исключён из чтения и операций, ID разбирается целиком. Init/User/Peer формы сохраняют поля при отказах по существующим шаблонам. Статический IPv4 проверяется до изменений; отсутствующий явный peer и дубликат клиента отклоняются. Зарезервированные имена mode-config учитываются в истории и очистке, поскольку RouterOS7.24.5 отклонил comment. + +**Совместимость RouterOS7.** Мастер создаёт сертификатную identity с digital-signature, certificate находится в identity; неподдерживаемые certificate/send-cert убраны из peer и restore body. Исправлены responder=yes для mode-config, доверие CA, неподдерживаемые profile/mode-config comment и identity place-before. Из генерации убраны DH1024/1536 и SHA1. Сертификатная роль учитывает identity references. Plain PSK remote ID при создании превращается в key-id. Отображаемое имя сертификатного клиента хранится в identity, подписанный CN/ключ/имя сертификата сохраняются. + +**Выбор объекта и удаление.** Клиенты используют явные rosId namespace identity:/cert:; совпадение ID разных RouterOS меню больше не выбирает чужой объект. Неоднозначный legacy ID даёт409 с предложением обновить список; CA не является клиентом. Удаление сервера заранее читает весь объём, сохраняет стандартный/чужой policy template, не скрывает ошибки и удаляет CA после leaf сертификатов. Чужие identity/policy и сертификат, подписанный управляемым CA, блокируют удаление до первой записи. Общий/чужой mode-config сохраняется при удалении клиента. Это проверки перечисленных ссылок, не гарантия учёта всех внешних потребителей PKI. + +**Контекст и интерфейс.** URL сохраняет server и tab при reload; неизвестное устройство остаётся отдельным срезом с ошибкой и заблокированным созданием клиента. Сохранена цель мастера при переключении контекста. Форма peer больше не предлагает сертификат, относящийся к identity. Реальный browser выявил возврат вкладки после закрытия Sheet; локальные query-state обновления переведены на History API без отдельного RSC перехода. [Next.js API](https://nextjs.org/docs/app/guides/single-page-applications). Тестовые PNG сохраняются в каталог конкретного запуска после ошибки записи общего файла Windows. + +**Проверки.** npm backend test:ipsec:67 новых проверок + config tests, entity-snapshots passed; backend/scoped frontend TypeScript и ESLint passed. Итоговый профильный набор30/30 (включает связанные GRE cases), context-race6/6. Реальный browser4/4: оба CHR ×1280/320px, готовность, peer/client формы, URL/tab/reload и отсутствие page overflow; без HTTP mocks. [Browser protocol](ux-ui/acceptance/browser-ipsec-section-2026-10-09.json). Полная регрессия проекта не повторялась. + +**Реальная схема.** На194.226.121.150 и104.165.244.7 выполнены HTTP init без NAT, создание/disable/enable/duplicate PSK клиента,400 на неверный IP,404 на отсутствующий peer, выпуск сертификатов, проверка .p12 через OpenSSL и сохранение CN при переименовании. Для сетевой проверки native REST создал собственные profile/proposal/peer/identity/policy/GRE и198.18.255.249/30↔.250/30; IPsec encrypt/require защищал GRE между публичными /32, без изменения firewall/default route. PSK, затем digital-signature с реальным экспортированным клиентским ключом: Ping3/3 в обе стороны, ph2-state established и ненулевые SA bytes. В импортированной цепочке CA/leaf получили одинаковое имя от RouterOS; тест назначает им уникальные имена по ID. Сертификатная роль задана явно: CHR1 responder, CHR2 initiator. Это native site-to-site проверка криптографии/экспорта; выдача road-warrior lease и импорт через приложение не объявляются проверенными. + +**Ошибки чтения и выключенные устройства.** Две собственные временные DB записи home-router/exit-node указывали на HTTP probe. Выключенные GET сообщали disabled, операции404, native calls0. Включение только fake endpoint вернуло настоящий upstream401: не было ложной успешной пустой сводки, два исправных CHR сохранились в частичном результате;16 probe calls на два чтения. Все fake записи удалены. [Native protocol](ux-ui/acceptance/ipsec-section-2026-10-09.json). + +**Развёртывание и состояние.** Образы backend/frontend ux-ipsec77/version1.0.0-test.ipsec77 собраны и развёрнуты;3 services running, backend/PG healthy, frontend проверен HTTP/browser. Source: архив базового HEAD e2d81f9 +14 явных working-file overrides, SHA256 e0a034a855eb2d07696a1849c9baed738890e7c84108d5a3d6dbe652188bee9e; [manifest](ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json). Это не архив одного уже закоммиченного revision. Приватный pre-deploy PG backup2510309 bytes/0600, pg_restore --list passed; .env/прежние VXLAN образы сохранены на сервере. DB после: servers2/speedHistory9/retention14/migrations9. Пароли не менялись; оба CHR/SSH доступны, единичный401 CHR2 при контрольном чтении не повторился. [Deployment](ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json), [smoke8 страниц](ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json). + +Очистка последнего цикла сравнила IPsec/PKI/GRE/IP/routes/IPv4 firewall/file metadata на обоих CHR: все проверки true. При восстановлении раннего цикла широкий отбор отклонила автоматическая проверка: выполнены инвентаризация и preflight17 точных ID/атрибутов собственного запуска, затем их разрешённая очистка. Удаление каталога flash наCHR2 отклонено отдельно: системное имя/пустота не доказывают ownership; каталог сохранён. Приватные .p12/.pass/.pem теста удалены, ключи не включены в отчёт/Git. + +| Референс | Схема и реальные экраны | +| --- | --- | +| [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Устройство/URL → достоверный срез → клиент; [ПК](ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png), [телефон](ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png) | +| [Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp), [Tabs API/preview](https://reui.io/components/tabs?ref=mcp) | Peer/клиент → сохранение ввода → подтверждение API → возврат в тот же срез | + +```mermaid +flowchart LR + A[Устройство и вкладка в URL] --> B{Достоверный срез?} + B -->|Нет| C[Ошибка и последние данные] + B -->|Да| D[Peer или клиент с явным ID] + D --> E[Форма и подтверждение API] + E --> F[Native проверка и возврат] +``` + +Остаточные границы: online/active-peer корреляция, настоящая выдача mode-config, NAT/интернет, IPv6, Windows/macOS/iOS/Android подключения, late failure receipts/DB/transport, history restore, многопроцессность, IPsec-specific live JWT/RBAC и throughput не приняты этим блоком. Основной раздел внедрён и принят в перечисленных сценариях; этап12.5 и эти профильные ограничения остаются открытыми. ## 12.5.76 — VXLAN: достоверность, экспорт и полная профильная приёмка (2026-10-09) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 171295d7..36f7b4f9 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт. +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -1846,3 +1846,41 @@ flowchart LR ``` Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе. + +## 12.5.77 — IPsec: основной раздел, RouterOS7 и реальная профильная приёмка (2026-10-09) + +Укрупнённый блок включает backend/API/UI, тесты, production Docker build/deploy, собственные реальные объекты на обоих CHR и очистку. Один локальный коммит после блока, без push. Установленные ReUI/Frame/base-nova компоненты сохранены. + +**Достоверность и операции.** Чтение восьми коллекций IPsec больше не скрывает отказ RouterOS или некорректный контейнер/строки/ID пустым успешным списком. Агрегатор сохраняет успешные устройства и сообщает ошибку конкретного устройства; observed history не запускается для неудачного среза. Выключенный сервер исключён из чтения и операций, ID разбирается целиком. Init/User/Peer формы сохраняют поля при отказах по существующим шаблонам. Статический IPv4 проверяется до изменений; отсутствующий явный peer и дубликат клиента отклоняются. Зарезервированные имена mode-config учитываются в истории и очистке, поскольку RouterOS7.24.5 отклонил comment. + +**Совместимость RouterOS7.** Мастер создаёт сертификатную identity с digital-signature, certificate находится в identity; неподдерживаемые certificate/send-cert убраны из peer и restore body. Исправлены responder=yes для mode-config, доверие CA, неподдерживаемые profile/mode-config comment и identity place-before. Из генерации убраны DH1024/1536 и SHA1. Сертификатная роль учитывает identity references. Plain PSK remote ID при создании превращается в key-id. Отображаемое имя сертификатного клиента хранится в identity, подписанный CN/ключ/имя сертификата сохраняются. + +**Выбор объекта и удаление.** Клиенты используют явные rosId namespace identity:/cert:; совпадение ID разных RouterOS меню больше не выбирает чужой объект. Неоднозначный legacy ID даёт409 с предложением обновить список; CA не является клиентом. Удаление сервера заранее читает весь объём, сохраняет стандартный/чужой policy template, не скрывает ошибки и удаляет CA после leaf сертификатов. Чужие identity/policy и сертификат, подписанный управляемым CA, блокируют удаление до первой записи. Общий/чужой mode-config сохраняется при удалении клиента. Это проверки перечисленных ссылок, не гарантия учёта всех внешних потребителей PKI. + +**Контекст и интерфейс.** URL сохраняет server и tab при reload; неизвестное устройство остаётся отдельным срезом с ошибкой и заблокированным созданием клиента. Сохранена цель мастера при переключении контекста. Форма peer больше не предлагает сертификат, относящийся к identity. Реальный browser выявил возврат вкладки после закрытия Sheet; локальные query-state обновления переведены на History API без отдельного RSC перехода. [Next.js API](https://nextjs.org/docs/app/guides/single-page-applications). Тестовые PNG сохраняются в каталог конкретного запуска после ошибки записи общего файла Windows. + +**Проверки.** npm backend test:ipsec:67 новых проверок + config tests, entity-snapshots passed; backend/scoped frontend TypeScript и ESLint passed. Итоговый профильный набор30/30 (включает связанные GRE cases), context-race6/6. Реальный browser4/4: оба CHR ×1280/320px, готовность, peer/client формы, URL/tab/reload и отсутствие page overflow; без HTTP mocks. [Browser protocol](ux-ui/acceptance/browser-ipsec-section-2026-10-09.json). Полная регрессия проекта не повторялась. + +**Реальная схема.** На194.226.121.150 и104.165.244.7 выполнены HTTP init без NAT, создание/disable/enable/duplicate PSK клиента,400 на неверный IP,404 на отсутствующий peer, выпуск сертификатов, проверка .p12 через OpenSSL и сохранение CN при переименовании. Для сетевой проверки native REST создал собственные profile/proposal/peer/identity/policy/GRE и198.18.255.249/30↔.250/30; IPsec encrypt/require защищал GRE между публичными /32, без изменения firewall/default route. PSK, затем digital-signature с реальным экспортированным клиентским ключом: Ping3/3 в обе стороны, ph2-state established и ненулевые SA bytes. В импортированной цепочке CA/leaf получили одинаковое имя от RouterOS; тест назначает им уникальные имена по ID. Сертификатная роль задана явно: CHR1 responder, CHR2 initiator. Это native site-to-site проверка криптографии/экспорта; выдача road-warrior lease и импорт через приложение не объявляются проверенными. + +**Ошибки чтения и выключенные устройства.** Две собственные временные DB записи home-router/exit-node указывали на HTTP probe. Выключенные GET сообщали disabled, операции404, native calls0. Включение только fake endpoint вернуло настоящий upstream401: не было ложной успешной пустой сводки, два исправных CHR сохранились в частичном результате;16 probe calls на два чтения. Все fake записи удалены. [Native protocol](ux-ui/acceptance/ipsec-section-2026-10-09.json). + +**Развёртывание и состояние.** Образы backend/frontend ux-ipsec77/version1.0.0-test.ipsec77 собраны и развёрнуты;3 services running, backend/PG healthy, frontend проверен HTTP/browser. Source: архив базового HEAD e2d81f9 +14 явных working-file overrides, SHA256 e0a034a855eb2d07696a1849c9baed738890e7c84108d5a3d6dbe652188bee9e; [manifest](ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json). Это не архив одного уже закоммиченного revision. Приватный pre-deploy PG backup2510309 bytes/0600, pg_restore --list passed; .env/прежние VXLAN образы сохранены на сервере. DB после: servers2/speedHistory9/retention14/migrations9. Пароли не менялись; оба CHR/SSH доступны, единичный401 CHR2 при контрольном чтении не повторился. [Deployment](ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json), [smoke8 страниц](ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json). + +Очистка последнего цикла сравнила IPsec/PKI/GRE/IP/routes/IPv4 firewall/file metadata на обоих CHR: все проверки true. При восстановлении раннего цикла широкий отбор отклонила автоматическая проверка: выполнены инвентаризация и preflight17 точных ID/атрибутов собственного запуска, затем их разрешённая очистка. Удаление каталога flash наCHR2 отклонено отдельно: системное имя/пустота не доказывают ownership; каталог сохранён. Приватные .p12/.pass/.pem теста удалены, ключи не включены в отчёт/Git. + +| Референс | Схема и реальные экраны | +| --- | --- | +| [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Устройство/URL → достоверный срез → клиент; [ПК](ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png), [телефон](ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png) | +| [Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp), [Tabs API/preview](https://reui.io/components/tabs?ref=mcp) | Peer/клиент → сохранение ввода → подтверждение API → возврат в тот же срез | + +```mermaid +flowchart LR + A[Устройство и вкладка в URL] --> B{Достоверный срез?} + B -->|Нет| C[Ошибка и последние данные] + B -->|Да| D[Peer или клиент с явным ID] + D --> E[Форма и подтверждение API] + E --> F[Native проверка и возврат] +``` + +Остаточные границы: online/active-peer корреляция, настоящая выдача mode-config, NAT/интернет, IPv6, Windows/macOS/iOS/Android подключения, late failure receipts/DB/transport, history restore, многопроцессность, IPsec-specific live JWT/RBAC и throughput не приняты этим блоком. Основной раздел внедрён и принят в перечисленных сценариях; этап12.5 и эти профильные ограничения остаются открытыми. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index de9cf37c..69cec086 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-09 -Сервер161.104.46.222 предоставлен для временного тестирования. Backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; backend/PostgreSQL healthy. SSH и оба CHR доступны с прежними доступами. Раздел VXLAN76 принят в описанных ниже границах; собственные объекты и временные выключенные DB записи удалены, selected baseline восстановлен. +Сервер161.104.46.222 предоставлен для временного тестирования. Backend/frontend ux-ipsec77/version1.0.0-test.ipsec77; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние. | Параметр | Проверенное состояние | | --- | --- | @@ -14,7 +14,7 @@ | PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed | | Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data | | Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска | -| Образы приложения | mmapp-test/backend:ux-vxlan76 и mmapp-test/frontend:ux-vxlan76; запущены | +| Образы приложения | mmapp-test/backend:ux-ipsec77 и mmapp-test/frontend:ux-ipsec77; запущены | Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example). @@ -689,3 +689,9 @@ flowchart LR ``` Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе. + +## 12.5.77 — основной блок IPsec (2026-10-09) + +Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт. + +Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). diff --git a/docs/ux-ui/acceptance/browser-ipsec-section-2026-10-09.json b/docs/ux-ui/acceptance/browser-ipsec-section-2026-10-09.json new file mode 100644 index 00000000..c5e926be --- /dev/null +++ b/docs/ux-ui/acceptance/browser-ipsec-section-2026-10-09.json @@ -0,0 +1,45 @@ +{ + "date": "2026-10-09", + "runtime": "ipsec77", + "httpMocks": false, + "cases": [ + { + "serverId": 1, + "width": 1280, + "ready": true, + "serverAndTabUrlPreserved": true, + "peerPropertiesOnly": true, + "clientFormFits": true, + "noPageOverflow": true + }, + { + "serverId": 1, + "width": 320, + "ready": true, + "serverAndTabUrlPreserved": true, + "peerPropertiesOnly": true, + "clientFormFits": true, + "noPageOverflow": true + }, + { + "serverId": 2, + "width": 1280, + "ready": true, + "serverAndTabUrlPreserved": true, + "peerPropertiesOnly": true, + "clientFormFits": true, + "noPageOverflow": true + }, + { + "serverId": 2, + "width": 320, + "ready": true, + "serverAndTabUrlPreserved": true, + "peerPropertiesOnly": true, + "clientFormFits": true, + "noPageOverflow": true + } + ], + "pageErrors": [], + "scope": "Real catalog, readiness, peer/client forms and URL reload; writes independently exercised through production HTTP/native checks" +} diff --git a/docs/ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json b/docs/ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json new file mode 100644 index 00000000..cbca2d04 --- /dev/null +++ b/docs/ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json @@ -0,0 +1,50 @@ +{ + "date": "2026-10-09", + "backendSource": "ipsec77", + "frontendSource": "ipsec77", + "httpMocks": false, + "pages": [ + { + "path": "/dashboard", + "status": 200, + "mobileFits": true + }, + { + "path": "/servers", + "status": 200, + "mobileFits": true + }, + { + "path": "/uptime?tab=speed", + "status": 200, + "mobileFits": true + }, + { + "path": "/settings", + "status": 200, + "mobileFits": true + }, + { + "path": "/filters", + "status": 200, + "mobileFits": true + }, + { + "path": "/wireguard", + "status": 200, + "mobileFits": true + }, + { + "path": "/vxlan", + "status": 200, + "mobileFits": true + }, + { + "path": "/ipsec", + "status": 200, + "mobileFits": true + } + ], + "pageErrors": [], + "badApiResponses": [] +} diff --git a/docs/ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json b/docs/ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json new file mode 100644 index 00000000..6de39ccb --- /dev/null +++ b/docs/ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json @@ -0,0 +1,39 @@ +{ + "date": "2026-10-09", + "version": "1.0.0-test.ipsec77", + "source": { + "baseHead": "e2d81f9", + "workingFileOverrides": 14, + "archiveSha256": "e0a034a855eb2d07696a1849c9baed738890e7c84108d5a3d6dbe652188bee9e", + "revisionLabel": "ipsec77" + }, + "images": { + "backend": "sha256:a4a730666c4690d5147e92f6afcb7bb5ef35132a4a1b92d291738477b9aada443", + "frontend": "sha256:452b015751c5c26bc5492cdade76149140ef9e776cbad6927a151c6eead8f028" + }, + "productionBuildsPassed": true, + "services": { + "running": 3, + "backend": "healthy", + "postgres": "healthy", + "frontend": "no healthcheck; HTTP/browser passed" + }, + "backup": { + "bytes": 2510309, + "mode": "0600", + "pgRestoreListPassed": true, + "privateEnvPreserved": true + }, + "databaseAfter": { + "servers": 2, + "speedHistory": 9, + "retentionDays": 14, + "migrations": 9 + }, + "previousVxlanImagesRetained": true, + "retainedMetadata": { + "serverId": 2, + "name": "flash", + "reason": "Automatic review rejected deletion without demonstrated ownership" + } +} diff --git a/docs/ux-ui/acceptance/ipsec-section-2026-10-09.json b/docs/ux-ui/acceptance/ipsec-section-2026-10-09.json new file mode 100644 index 00000000..7455aa7e --- /dev/null +++ b/docs/ux-ui/acceptance/ipsec-section-2026-10-09.json @@ -0,0 +1,372 @@ +{ + "date": "2026-10-09", + "runtime": "ipsec77", + "realRouterOs": true, + "name": "mm-ipsec-1791524379334", + "apiOperations": [ + { + "method": "GET", + "path": "/ipsec?serverId=17", + "status": 200 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 404 + }, + { + "method": "PATCH", + "path": "/ipsec/peers/:id", + "status": 404 + }, + { + "method": "DELETE", + "path": "/ipsec/server/17", + "status": 404 + }, + { + "method": "GET", + "path": "/ipsec?serverId=18", + "status": 200 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 404 + }, + { + "method": "PATCH", + "path": "/ipsec/peers/:id", + "status": 404 + }, + { + "method": "DELETE", + "path": "/ipsec/server/18", + "status": 404 + }, + { + "method": "GET", + "path": "/ipsec?serverId=17", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec", + "status": 200 + }, + { + "method": "POST", + "path": "/ipsec/server/init", + "status": 201 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 201 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 409 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 400 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 404 + }, + { + "method": "PATCH", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 201 + }, + { + "method": "POST", + "path": "/ipsec/server/init", + "status": 201 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 201 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 409 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 400 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 404 + }, + { + "method": "PATCH", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "POST", + "path": "/ipsec/users", + "status": 201 + }, + { + "method": "PATCH", + "path": "/ipsec/peers/:id", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/peers/:id", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/peers/:id", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/peers/:id", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec?serverId=1", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec?serverId=1", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec?serverId=2", + "status": 200 + }, + { + "method": "PATCH", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec?serverId=2", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec?serverId=1", + "status": 200 + }, + { + "method": "DELETE", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "DELETE", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "DELETE", + "path": "/ipsec/server/1", + "status": 200 + }, + { + "method": "GET", + "path": "/ipsec?serverId=2", + "status": 200 + }, + { + "method": "DELETE", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "DELETE", + "path": "/ipsec/users/:id", + "status": 200 + }, + { + "method": "DELETE", + "path": "/ipsec/server/2", + "status": 200 + } + ], + "cleanup": [ + { + "serverId": 1, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/profile": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/mode-config": true, + "/ip/pool": true, + "/ip/firewall/nat": true, + "/certificate": true, + "/interface/gre": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/filter": true, + "/ip/firewall/mangle": true, + "/file": true + }, + { + "serverId": 2, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/profile": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/mode-config": true, + "/ip/pool": true, + "/ip/firewall/nat": true, + "/certificate": true, + "/interface/gre": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/filter": true, + "/ip/firewall/mangle": true, + "/file": true + } + ], + "disabledChecks": [ + { + "type": "home-router", + "readFailure": "disabled", + "mutationsStatus": 404, + "noNativeRequests": true + }, + { + "type": "exit-node", + "readFailure": "disabled", + "mutationsStatus": 404, + "noNativeRequests": true + } + ], + "disabledNativeProbeCalls": 0, + "realReadFailure": { + "status": 401, + "noFalseEmptySummary": true, + "partialSuccessfulServers": 2, + "probeCalls": 16 + }, + "fakeRecordsRemoved": true, + "init": [ + { + "serverId": 1, + "initialized": true, + "identityAuth": "digital-signature", + "certificateRole": "server" + }, + { + "serverId": 2, + "initialized": true, + "identityAuth": "digital-signature", + "certificateRole": "server" + } + ], + "clientChecks": [ + { + "serverId": 1, + "pskCreateToggleDuplicate": true, + "invalidIp400": true, + "missingPeer404": true, + "certificateIssued": true, + "pkcs12Validated": true, + "secretInPublicReport": false + }, + { + "serverId": 2, + "pskCreateToggleDuplicate": true, + "invalidIp400": true, + "missingPeer404": true, + "certificateIssued": true, + "pkcs12Validated": true, + "secretInPublicReport": false + } + ], + "encryptedPings": [ + { + "serverId": 1, + "received": 3, + "policyState": "established", + "installedSaCount": 2, + "nonzeroSaBytes": true + }, + { + "serverId": 2, + "received": 3, + "policyState": "established", + "installedSaCount": 2, + "nonzeroSaBytes": true + } + ], + "importedCertificateNamesUnique": true, + "certificatePings": [ + { + "serverId": 1, + "received": 3, + "policyState": "established", + "digitalSignatureConfigured": true, + "exportedClientCertificateUsed": true, + "nonzeroSaBytes": true + }, + { + "serverId": 2, + "received": 3, + "policyState": "established", + "digitalSignatureConfigured": true, + "exportedClientCertificateUsed": true, + "nonzeroSaBytes": true + } + ], + "activePeerEvidence": [ + { + "serverId": 1, + "active": [] + }, + { + "serverId": 2, + "active": [] + } + ] +} diff --git a/docs/ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json b/docs/ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json new file mode 100644 index 00000000..626c5ce9 --- /dev/null +++ b/docs/ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json @@ -0,0 +1,58 @@ +[ + { + "path": "backend/package.json", + "sha256": "a257772b6dfa4a5789e8052050cfe108b6f17abcab60652b2d103e338352ce9e" + }, + { + "path": "backend/src/routes/ipsec.ts", + "sha256": "0f659b83e1a6659fce430674c012b4b802d84c305e0c7cb2121e542e27ec5c05" + }, + { + "path": "backend/src/services/entity-snapshots.ts", + "sha256": "3bfc9ec79b766bba78822f8cd0cb52c6a0f9275cce103230a8461fb4568d02c6" + }, + { + "path": "backend/src/services/ipsec-ca.ts", + "sha256": "62d6dc31e825e72b5bb97fdbca148ed4a961b83f40c2fd26dae2ad02ca6e2499" + }, + { + "path": "backend/src/services/ipsec-config.test.ts", + "sha256": "2b440b45d0e34badcbbb47805652c068edea1cf510e4800c8c2f34f9ac573c3f" + }, + { + "path": "backend/src/services/ipsec-config.ts", + "sha256": "0211752033b34d9ab2b78ff7c211caac8a7785e68ecd32b155ed997eccb66c1d" + }, + { + "path": "backend/src/services/ipsec-live.ts", + "sha256": "870c56d23ef7444588a9a95ea023426308da220675a86d426b86732ae2f4dd5c" + }, + { + "path": "backend/src/services/ipsec-ros.ts", + "sha256": "f52dd291f916d20578614789825134882ce1669605225879459f6f92b0c1872b" + }, + { + "path": "backend/src/services/ipsec-cleanup.ts", + "sha256": "08ffa7ad0ca1c9315720d80f1281279da0ccd4264c33abce9dda49df35050624" + }, + { + "path": "backend/src/services/ipsec-state.test.ts", + "sha256": "bc81e603e94d947596fb2c8a1cd23ab06efdd0a2b2a4ffaee607b1a36b512385" + }, + { + "path": "app/(main)/ipsec/page.tsx", + "sha256": "d1edad870a79161313c9718b2c448953dfa7a4e0e1ee7de4e5e478c8800fd558" + }, + { + "path": "components/ipsec/ipsec-peer-sheet.tsx", + "sha256": "3cd72d864dc6fd34077f279e82fc3a1ee4e437e8c42b993957120f60a1419324" + }, + { + "path": "tests/e2e/ipsec-links.spec.ts", + "sha256": "86909015ed7746047e5cb0d64f53d238fede520144dfb97fe2f537188964318f" + }, + { + "path": "tests/e2e/ipsec-layout.spec.ts", + "sha256": "7c4fac163b0a26e175d60d63809724d4857f3672d05b1328049bf8a21ca4dc99" + } +] diff --git a/docs/ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png b/docs/ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png new file mode 100644 index 00000000..75283185 Binary files /dev/null and b/docs/ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png differ diff --git a/docs/ux-ui/screens/ipsec-real-clients-server1-320-2026-10-09.png b/docs/ux-ui/screens/ipsec-real-clients-server1-320-2026-10-09.png new file mode 100644 index 00000000..806671c2 Binary files /dev/null and b/docs/ux-ui/screens/ipsec-real-clients-server1-320-2026-10-09.png differ diff --git a/docs/ux-ui/screens/ipsec-real-clients-server2-1280-2026-10-09.png b/docs/ux-ui/screens/ipsec-real-clients-server2-1280-2026-10-09.png new file mode 100644 index 00000000..74aa9dff Binary files /dev/null and b/docs/ux-ui/screens/ipsec-real-clients-server2-1280-2026-10-09.png differ diff --git a/docs/ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png b/docs/ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png new file mode 100644 index 00000000..8427463b Binary files /dev/null and b/docs/ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png differ diff --git a/tests/e2e/ipsec-layout.spec.ts b/tests/e2e/ipsec-layout.spec.ts index 07c2a774..5002beeb 100644 --- a/tests/e2e/ipsec-layout.spec.ts +++ b/tests/e2e/ipsec-layout.spec.ts @@ -27,7 +27,7 @@ for (const device of ["desktop", "mobile"] as const) { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }) mkdirSync("docs/ux-ui/screens", { recursive: true }) - await page.screenshot({ path: `docs/ux-ui/screens/ipsec-${device}.png`, animations: "disabled" }) + await page.screenshot({ path: test.info().outputPath(`ipsec-${device}.png`), animations: "disabled" }) if (device === "mobile") { await page.getByRole("button", { name: "Серверы", exact: true }).click() await page.getByRole("dialog").getByRole("option").filter({ hasText: "Резервный VPN роутер" }).click() @@ -40,7 +40,7 @@ for (const device of ["desktop", "mobile"] as const) { const dialog = page.getByRole("dialog") await dialog.getByRole("textbox", { name: "Имя клиента" }).fill("Проверенный клиент") await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().left >= 0 && el.getBoundingClientRect().right <= innerWidth)).toBe(true) - await page.screenshot({ path: `docs/ux-ui/screens/ipsec-client-${device}.png`, animations: "disabled" }) + await page.screenshot({ path: test.info().outputPath(`ipsec-client-${device}.png`), animations: "disabled" }) await dialog.getByRole("button", { name: "Сохранить", exact: true }).focus() await page.keyboard.press("Enter") await expect(dialog).toHaveCount(0) @@ -52,6 +52,6 @@ for (const device of ["desktop", "mobile"] as const) { await page.clock.fastForward(6000) await expect(page.getByText("Клиент обновлён", { exact: true })).toHaveCount(0) if (device === "mobile") await page.getByRole("button", { name: "Переинициализировать", exact: true }).scrollIntoViewIfNeeded() - await page.screenshot({ path: `docs/ux-ui/screens/ipsec-servers-${device}.png`, animations: "disabled" }) + await page.screenshot({ path: test.info().outputPath(`ipsec-servers-${device}.png`), animations: "disabled" }) }) } diff --git a/tests/e2e/ipsec-links.spec.ts b/tests/e2e/ipsec-links.spec.ts new file mode 100644 index 00000000..b8365035 --- /dev/null +++ b/tests/e2e/ipsec-links.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "@playwright/test" + +test.beforeEach(async ({ page }) => { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const p = new URL(r.request().url()).pathname + return r.fulfill({ json: p === "/api/auth/config" ? { required: false } : p === "/api/servers" ? [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, type: "home-router", country: "RU", status: "online" })) : p === "/api/ipsec" ? { servers: [1, 2].map(id => ({ serverId: String(id), serverName: "Роутер " + id, initialized: false, ike2Ready: false, natRuleManaged: false, clientsTotal: 0, clientsOnline: 0, peers: [] })), clients: [] } : {} }) + }) +}) + +test("IPsec: URL сохраняет устройство и вкладку после reload", async ({ page }) => { + await page.goto("/ipsec?server=2&tab=server") + await expect(page.getByRole("tab", { name: "Сервер", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(page.getByRole("button", { name: "Инициализировать", exact: true })).toHaveCount(1) + await page.getByRole("tab", { name: "Клиенты", exact: true }).click() + await expect(page).toHaveURL(/server=2$/) + await page.reload() + await expect(page.getByRole("tab", { name: "Клиенты", exact: true })).toHaveAttribute("aria-selected", "true") + await page.getByRole("tab", { name: "Сервер", exact: true }).click() + await expect(page).toHaveURL(/server=2&tab=server$/) +}) + +test("IPsec: неизвестное устройство из URL не подменяется общим срезом", async ({ page }) => { + await page.goto("/ipsec?server=999&tab=server") + await expect(page.getByRole("alert").filter({ hasText: "Устройство из ссылки не найдено" })).toBeVisible() + await expect(page.getByRole("button", { name: "Инициализировать", exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Новый клиент", exact: true })).toBeDisabled() +})