fix(ipsec): подтвердить достоверность операций и приёмку раздела

This commit is contained in:
Denozordec committed 2026-10-09 12:46:22 +07:00
1 parent e2d81f9657
commit f80d9b7637
28 files changed
+969 -170

No files matched your search

+30 -24
View File
@@ -1,7 +1,8 @@
"use client" "use client"
import { z } from "zod" 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 { PageHeader } from "@/components/page-header"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import type { Server } 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(), latency: z.number().nullable().optional(), asn: z.string().optional(),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) })).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 { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
@@ -201,10 +202,9 @@ function IpsecPage() {
const certPending = useRef(false) const certPending = useRef(false)
useEffect(() => () => { certEpoch.current += 1 }, []) useEffect(() => () => { certEpoch.current += 1 }, [])
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [initOpen, setInitOpen] = useState(false) const [initOpen, setInitOpen] = useState(false)
const [initTargetId, setInitTargetId] = useState<string | undefined>()
const [userOpen, setUserOpen] = useState(false) const [userOpen, setUserOpen] = useState(false)
const [editing, setEditing] = useState<IpsecClientDto | null>(null) const [editing, setEditing] = useState<IpsecClientDto | null>(null)
const [certOpen, setCertOpen] = useState(false) const [certOpen, setCertOpen] = useState(false)
@@ -265,10 +265,8 @@ function IpsecPage() {
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
const effectiveServerId = const effectiveServerId = selectedServerId
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) const missingSelectedServer = selectedServerId !== ALL_SERVERS_ID && hasLoaded && !displayServers.some(s => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
@@ -315,7 +313,7 @@ function IpsecPage() {
} }
const failedServerIds = new Set(failures.map(f => f.serverId)) 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 && const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId) !loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
@@ -337,7 +335,7 @@ function IpsecPage() {
operationLock.current = false operationLock.current = false
if (mounted.current) setBusy(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(() => { const scopedClients = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayClients if (effectiveServerId === ALL_SERVERS_ID) return displayClients
@@ -366,10 +364,6 @@ function IpsecPage() {
const caOk = scopedSummaries.some((s) => s.caCert) const caOk = scopedSummaries.some((s) => s.caCert)
const compactServer = effectiveServerId !== ALL_SERVERS_ID const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined 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( const peerOptions = useMemo(
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))), () => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
[scopedSummaries], [scopedSummaries],
@@ -583,7 +577,6 @@ function IpsecPage() {
address: form.address.trim() || undefined, address: form.address.trim() || undefined,
exchangeMode: form.exchangeMode.trim() || undefined, exchangeMode: form.exchangeMode.trim() || undefined,
passive: form.passive, passive: form.passive,
certificate: form.certificate.trim() || undefined,
profile: form.profile.trim() || undefined, profile: form.profile.trim() || undefined,
disabled: form.disabled, disabled: form.disabled,
}) })
@@ -626,6 +619,7 @@ function IpsecPage() {
<> <>
<ServerRailLayout <ServerRailLayout
items={railItems} items={railItems}
preserveSelection
selectedId={effectiveServerId} selectedId={effectiveServerId}
onSelect={setSelectedServerId} onSelect={setSelectedServerId}
showAll showAll
@@ -675,6 +669,7 @@ function IpsecPage() {
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{missingSelectedServer && <Alert variant="destructive" role="alert"><AlertTitle>Устройство из ссылки не найдено</AlertTitle><AlertDescription>Выберите доступный сервер. Данные других устройств не подставляются в этот срез.</AlertDescription></Alert>}
<KpiStatGrid <KpiStatGrid
aria-label="Сводка IPsec" aria-label="Сводка IPsec"
items={[ items={[
@@ -799,7 +794,7 @@ function IpsecPage() {
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid {isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
servers={scopedSummaries} servers={scopedSummaries}
canManage={canManage} canManage={canManage}
onInit={isLive ? (s) => { 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} onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
onEditPeer={isLive ? openEditPeer : undefined} onEditPeer={isLive ? openEditPeer : undefined}
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined} onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
@@ -831,12 +826,10 @@ function IpsecPage() {
lines: [ lines: [
"/ip ipsec peer add \\", "/ip ipsec peer add \\",
" name=ipsec-vpn address=0.0.0.0/0 \\", " name=ipsec-vpn address=0.0.0.0/0 \\",
" exchange-mode=ike2 passive=yes \\", " exchange-mode=ike2 passive=yes profile=ipsec-vpn",
" certificate=ipsec-server send-cert=always",
"/ip ipsec identity add \\", "/ip ipsec identity add \\",
" peer=ipsec-vpn auth-method=rsa-key \\", " peer=ipsec-vpn auth-method=digital-signature \\",
" certificate=ipsec-server \\", " certificate=ipsec-server \\",
" match-by=certificate \\",
" generate-policy=port-strict \\", " generate-policy=port-strict \\",
" mode-config=ipsec-vpn", " mode-config=ipsec-vpn",
], ],
@@ -892,7 +885,6 @@ function IpsecPage() {
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }} onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
busy={busy} busy={busy}
editing={editingPeer?.peer ?? null} editing={editingPeer?.peer ?? null}
certificates={certNames}
onSubmit={handleEditPeer} onSubmit={handleEditPeer}
/> />
@@ -901,7 +893,7 @@ function IpsecPage() {
onOpenChange={setInitOpen} onOpenChange={setInitOpen}
servers={serverOptions.filter(s => canManage(s.id) || busy)} servers={serverOptions.filter(s => canManage(s.id) || busy)}
busy={busy} busy={busy}
defaultServerId={sheetServerId} defaultServerId={initTargetId ?? sheetServerId}
onSubmit={handleInit} onSubmit={handleInit}
/> />
@@ -959,7 +951,21 @@ function IpsecPage() {
) )
} }
export default function IpsecSource() { function IpsecRoute() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
return <IpsecPage key={JSON.stringify([mode, backendUrl])} /> 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 <IpsecPage key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} setSelectedServerId={id => navigate("server", id, ALL_SERVERS_ID)} workspaceTab={workspaceTab} setWorkspaceTab={tab => navigate("tab", tab, "clients")} />
}
export default function IpsecSource() {
return <Suspense fallback={<div role="status">Загрузка IPsec…</div>}><IpsecRoute /></Suspense>
} }
+2 -1
View File
@@ -28,8 +28,9 @@
"test:ros-file": "tsx src/services/ros-file-contents.test.ts", "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: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: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: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: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-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", "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",
+37 -53
View File
@@ -14,7 +14,6 @@ import {
} from "@mmapp/contracts/ipsec" } from "@mmapp/contracts/ipsec"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { import {
IPSEC_CA_CERT,
IPSEC_COMMON_NAME, IPSEC_COMMON_NAME,
IPSEC_SERVER_CERT, IPSEC_SERVER_CERT,
buildClientInstructions, buildClientInstructions,
@@ -49,7 +48,6 @@ import {
patchPeer, patchPeer,
deletePeer, deletePeer,
ensureSharedIke2Identity, ensureSharedIke2Identity,
listByPath,
} from "../services/ipsec-ros.js" } from "../services/ipsec-ros.js"
import { import {
ensureCaCertificate, ensureCaCertificate,
@@ -65,6 +63,7 @@ import {
getEnabledIpsecServerById, getEnabledIpsecServerById,
listIpsec, listIpsec,
mapClients, mapClients,
resolveIpsecClientTarget,
mapServerSummary, mapServerSummary,
} from "../services/ipsec-live.js" } from "../services/ipsec-live.js"
import { import {
@@ -75,11 +74,12 @@ import {
} from "../services/config-revisions.js" } from "../services/config-revisions.js"
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js" import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js" import { executeRosOps } from "../services/ros-ops.js"
import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { isIP } from "node:net"
import { randomBytes } from "node:crypto" import { randomBytes } from "node:crypto"
import { z } from "zod" import { z } from "zod"
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
function generatePassphrase(): string { function generatePassphrase(): string {
return randomBytes(9).toString("base64url") return randomBytes(9).toString("base64url")
@@ -174,7 +174,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
const sid = parseDbServerId(q.serverId) const sid = parseDbServerId(q.serverId)
if (sid !== null) { if (sid !== null) {
const server = await getEnabledIpsecServerById(sid) 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) 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 ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment) await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, 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) { if (body.createNatRule) {
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN")) await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
} }
@@ -225,31 +226,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) { await removeManagedIpsecServer(client, removeCertificates)
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 recordIpsec(server, "apply") await recordIpsec(server, "apply")
return reply.send({ ok: true }) return reply.send({ ok: true })
} catch (e) { } catch (e) {
@@ -275,7 +252,6 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME) ?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
?? ike2Peers[0] ?? ike2Peers[0]
?? state.peers[0]
if (!peer) { if (!peer) {
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте 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-")) .find((n) => n && !n.startsWith("mc-ipsec-"))
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined) const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME) ?? state.modeConfigs.find((m) => (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) const caName = resolveIke2CaName(state.certs, serverCertRow)
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) { if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
return reply.status(400).send({ return reply.status(400).send({
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK", 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 peerName = (peer.name ?? "").trim()
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
const dns = sharedMc?.["static-dns"]?.trim() || undefined const dns = sharedMc?.["static-dns"]?.trim() || undefined
@@ -309,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
let modeConfig = (sharedMc?.name ?? "").trim() let modeConfig = (sharedMc?.name ?? "").trim()
if (body.staticIp) { if (body.staticIp) {
const ip = body.staticIp.trim() 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) const taken = takenStaticIps(state.modeConfigs)
if (taken.includes(ip)) { if (taken.includes(ip)) {
return reply.status(409).send({ error: `IP ${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() }) return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
} }
const d = parsed.data 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)) const server = await getEnabledIpsecServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" }) if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server) const client = MikrotikClient.fromServer(server)
try { try {
const state = await fetchIpsecState(server) const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId) const targetRosId = rosIdParam(rosId)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId) const target = resolveIpsecClientTarget(state, targetRosId)
const certRow = identity if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
? undefined let { identity, certRow } = target
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId) if (certRow) {
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId) identity = state.identities.find(i => i["remote-certificate"] === certRow?.name)
if (identity) certRow = undefined
}
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" }) if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
const ike2Peers = selectIke2Peers(state.peers) const ike2Peers = selectIke2Peers(state.peers)
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean) 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 const managedIdentity = identity
? isIpsecManagedComment(identity.comment) ? isIpsecManagedComment(identity.comment)
: String(certRow?.name ?? "").startsWith("ipsec-user-") : String(certRow?.name ?? "").startsWith("ipsec-user-")
@@ -419,9 +400,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(), comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
}) })
} }
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем) // Preserve the signed certificate CN/key/name; keep the editable display name in an identity.
if (certRow?.[".id"] && managedIdentity) { if (certRow && !d.staticIp) {
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) }) 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) if (personal) await deleteUserModeConfig(client, personal)
} else { } else {
const ip = d.staticIp.trim() 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( const others = takenStaticIps(
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc), state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
) )
@@ -446,7 +429,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
} else if (!identity && certRow && d.staticIp) { } else if (!identity && certRow && d.staticIp) {
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config // cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
const ip = d.staticIp.trim() 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) const taken = takenStaticIps(state.modeConfigs)
if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` }) if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0] const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
@@ -491,11 +474,10 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
try { try {
const state = await fetchIpsecState(server) const state = await fetchIpsecState(server)
const targetRosId = rosIdParam(rosId) const targetRosId = rosIdParam(rosId)
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId) const target = resolveIpsecClientTarget(state, targetRosId)
const certRow = identity if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
? undefined const identity = target.identity ?? (target.certRow ? state.identities.find(i => i["remote-certificate"] === target.certRow?.name) : undefined)
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId) const certRow = identity ? undefined : target.certRow
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" }) if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
const managed = identity const managed = identity
? isIpsecManagedComment(identity.comment) ? isIpsecManagedComment(identity.comment)
@@ -514,11 +496,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (removeCertificate) { if (removeCertificate) {
// удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем // удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
const ike2Peers = selectIke2Peers(state.peers) const ike2Peers = selectIke2Peers(state.peers)
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers)) const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities))
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim() const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers, state.identities)?.name ?? "").trim()
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "") const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
if (certName && certName !== caName && certName !== serverCertName) { if (certName && certName !== caName && certName !== serverCertName) {
await client.removeCertificate(certName).catch(() => undefined) await client.removeCertificate(certName)
} }
} }
await recordIpsec(server, "apply") await recordIpsec(server, "apply")
@@ -547,7 +529,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
if (d.address !== undefined) body.address = d.address if (d.address !== undefined) body.address = d.address
if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode
if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no" 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.profile !== undefined) body.profile = d.profile
if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no" if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no"
if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body) 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) const client = MikrotikClient.fromServer(server)
try { try {
const state = await fetchIpsecState(server) 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: "Клиент не найден" }) if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
const userName = parseIpsecUserComment(identity.comment) ?? "" const userName = parseIpsecUserComment(identity.comment) ?? ""
if (!userName) return reply.status(400).send({ error: "Не managed-клиент" }) if (!userName) return reply.status(400).send({ error: "Не managed-клиент" })
-3
View File
@@ -693,8 +693,6 @@ function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
address: p.address, address: p.address,
"exchange-mode": p.exchangeMode, "exchange-mode": p.exchangeMode,
passive: rosYesNo(p.passive), passive: rosYesNo(p.passive),
certificate: p.certificate,
"send-cert": "always",
profile: p.profile, profile: p.profile,
comment: p.comment, comment: p.comment,
disabled: rosYesNo(p.disabled), disabled: rosYesNo(p.disabled),
@@ -723,7 +721,6 @@ function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string>
"address-pool": m.addressPool, "address-pool": m.addressPool,
address: m.address, address: m.address,
"static-dns": m.staticDns, "static-dns": m.staticDns,
comment: m.comment,
}) })
} }
+2
View File
@@ -48,6 +48,8 @@ export async function ensureCaCertificate(client: MikrotikClient, daysValid: num
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid }) await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
await waitCertificateSigned(client, IPSEC_CA_CERT) 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 return IPSEC_CA_CERT
} }
+32
View File
@@ -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<void> {
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<string, string | undefined>) => 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"])}`)
}
}
}
+4 -1
View File
@@ -36,6 +36,8 @@ import { identityRosBody } from "./ipsec-ros.js"
assert.ok(!isIpsecManagedComment(undefined)) assert.ok(!isIpsecManagedComment(undefined))
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`) assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "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("не managed"), null)
assert.equal(parseIpsecUserComment(null), null) assert.equal(parseIpsecUserComment(null), null)
} }
@@ -187,7 +189,8 @@ import { identityRosBody } from "./ipsec-ros.js"
remoteCertificate: "ipsec-user-alice", remoteCertificate: "ipsec-user-alice",
placeBefore: "*S", 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["remote-certificate"], "ipsec-user-alice")
assert.equal(body["match-by"], "certificate") assert.equal(body["match-by"], "certificate")
} }
+13 -7
View File
@@ -11,7 +11,12 @@ export function ipsecManagedComment(label = ""): string {
} }
export function isIpsecManagedComment(comment: string | undefined | null): boolean { 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=<имя>`. */ /** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
@@ -20,8 +25,8 @@ export function ipsecUserComment(name: string): string {
} }
export function parseIpsecUserComment(comment: string | undefined | null): string | null { export function parseIpsecUserComment(comment: string | undefined | null): string | null {
if (!comment) return null if (!isIpsecManagedComment(comment)) return null
const m = /user=([^\s]+)/.exec(comment.trim()) const m = /(?:^|\s)user=(.+)$/.exec(comment!.trim())
return m?.[1] ?? null return m?.[1] ?? null
} }
@@ -150,7 +155,7 @@ export function isIke2RemoteAccessIdentity(
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim()) const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").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())) return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
} }
@@ -176,8 +181,9 @@ export function selectSharedIke2Identity<T extends Ike2IdentityLike>(
} }
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */ /** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined { export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[], identities: Ike2IdentityLike[] = []): T | undefined {
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean)) 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())) 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.name ?? "").trim() === IPSEC_SERVER_CERT)
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server")) ?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
@@ -297,7 +303,7 @@ export function buildClientInstructions(args: {
`1. Скачайте и импортируйте сертификат ${args.p12Filename}.`, `1. Скачайте и импортируйте сертификат ${args.p12Filename}.`,
pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.", pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.",
"2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»", "2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»",
" (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).", " Импортируйте .p12 в «Локальный компьютер» → «Личный», CA — в доверенные корневые сертификаты.",
"3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),", "3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),",
" аутентификация — сертификат; удалённый ID = CN серверного сертификата.", " аутентификация — сертификат; удалённый ID = CN серверного сертификата.",
"4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).", "4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).",
+52 -33
View File
@@ -1,4 +1,5 @@
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js" import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
import { parseDbServerId } from "../utils/server-id.js"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { servers } from "../db/schema.js" import { servers } from "../db/schema.js"
@@ -20,6 +21,7 @@ import {
identityDisplayName, identityDisplayName,
isIke2RemoteAccessIdentity, isIke2RemoteAccessIdentity,
isIpsecManagedComment, isIpsecManagedComment,
isManagedIpsecModeConfig,
resolveIke2CaName, resolveIke2CaName,
resolveIke2ServerCert, resolveIke2ServerCert,
selectIke2Peers, selectIke2Peers,
@@ -196,18 +198,19 @@ export interface IpsecServerState {
certs: RosCertRow[] certs: RosCertRow[]
} }
export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerState> { export async function fetchIpsecState(server: ServerRow, reader?: MikrotikClient): Promise<IpsecServerState> {
const client = MikrotikClient.fromServer(server) if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
const empty = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []) const client = reader ?? MikrotikClient.fromServer(server)
const collection = async <T>(path: string): Promise<T[]> => {
const raw = await client.get<unknown>(path)
if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"])) throw new Error(`Некорректный ответ IPsec: ${path}`)
return raw as T[]
}
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([ const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
client.get<unknown>("/ip/ipsec/peer").then((v) => empty<RosIpsecPeer>(v)).catch(() => [] as RosIpsecPeer[]), collection<RosIpsecPeer>("/ip/ipsec/peer"), collection<RosIpsecIdentity>("/ip/ipsec/identity"),
client.get<unknown>("/ip/ipsec/identity").then((v) => empty<RosIpsecIdentity>(v)).catch(() => [] as RosIpsecIdentity[]), collection<RosIpsecModeConfig>("/ip/ipsec/mode-config"), collection<RosIpsecPool>("/ip/pool"),
client.get<unknown>("/ip/ipsec/mode-config").then((v) => empty<RosIpsecModeConfig>(v)).catch(() => [] as RosIpsecModeConfig[]), collection<RosIpsecPolicy>("/ip/ipsec/policy"), collection<RosFirewallNat>("/ip/firewall/nat"),
client.get<unknown>("/ip/pool").then((v) => empty<RosIpsecPool>(v)).catch(() => [] as RosIpsecPool[]), collection<RosIpsecActivePeer>("/ip/ipsec/active-peers"), collection<RosCertRow>("/certificate"),
client.get<unknown>("/ip/ipsec/policy").then((v) => empty<RosIpsecPolicy>(v)).catch(() => [] as RosIpsecPolicy[]),
client.get<unknown>("/ip/firewall/nat").then((v) => empty<RosFirewallNat>(v)).catch(() => [] as RosFirewallNat[]),
client.get<unknown>("/ip/ipsec/active-peers").then((v) => empty<RosIpsecActivePeer>(v)).catch(() => [] as RosIpsecActivePeer[]),
client.getCertificates().catch(() => [] as RosCertRow[]),
]) ])
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs } 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 serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
const roleCtx: CertificateRoleContext = { 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), 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 shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined 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) const active = (cn ? activeByRemote.get(cn) : undefined)
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined) ?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
out.push({ out.push({
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`, id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName), rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
serverId: String(server.id), serverId: String(server.id),
serverName, serverName,
name: cn || certName, name: identity ? identityDisplayName(identity, state.certs) : (cn || certName),
authMethod: "certificate", authMethod: "certificate",
kind: "cert", kind: "cert",
certificateName: certName, certificateName: certName,
@@ -305,8 +308,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
const remoteId = (i["remote-id"] ?? "").trim() const remoteId = (i["remote-id"] ?? "").trim()
const active = remoteId ? activeByRemote.get(remoteId) : undefined const active = remoteId ? activeByRemote.get(remoteId) : undefined
out.push({ out.push({
id: `${server.id}:${rosId || "identity"}`, id: `${server.id}:identity:${rosId}`,
rosId: rosId || "identity", rosId: `identity:${rosId}`,
serverId: String(server.id), serverId: String(server.id),
serverName, serverName,
name: identityDisplayName(i, state.certs), 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)) 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 { export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
const server = state.server const server = state.server
const ike2Peers = selectIke2Peers(state.peers) const ike2Peers = selectIke2Peers(state.peers)
@@ -353,12 +366,12 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
?? state.pools.find((p) => isIpsecManagedComment(p.comment)) ?? state.pools.find((p) => isIpsecManagedComment(p.comment))
: undefined : undefined
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers) const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
const caName = resolveIke2CaName(state.certs, serverCertRow) const caName = resolveIke2CaName(state.certs, serverCertRow)
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
const roleCtx: CertificateRoleContext = { 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), identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
} }
const referencedCertNames = new Set<string>([ const referencedCertNames = new Set<string>([
@@ -432,7 +445,7 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
disabled: asBool(i.disabled), disabled: asBool(i.disabled),
})), })),
modeConfigs: state.modeConfigs modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment)) .filter(isManagedIpsecModeConfig)
.map((m) => ({ .map((m) => ({
name: (m.name ?? "").trim(), name: (m.name ?? "").trim(),
addressPool: (m["address-pool"] ?? "").trim(), addressPool: (m["address-pool"] ?? "").trim(),
@@ -496,7 +509,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
.map((i) => ({ .map((i) => ({
rosId: String(i[".id"] ?? ""), rosId: String(i[".id"] ?? ""),
peerName: (i.peer ?? "").trim(), 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(), certificate: (i.certificate ?? "").trim(),
remoteCertificate: (i["remote-certificate"] ?? "").trim(), remoteCertificate: (i["remote-certificate"] ?? "").trim(),
matchBy: (i["match-by"] ?? "").trim(), matchBy: (i["match-by"] ?? "").trim(),
@@ -508,7 +521,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
disabled: asBool(i.disabled), disabled: asBool(i.disabled),
})), })),
modeConfigs: state.modeConfigs modeConfigs: state.modeConfigs
.filter((m) => isIpsecManagedComment(m.comment)) .filter(isManagedIpsecModeConfig)
.map((m) => ({ .map((m) => ({
rosId: String(m[".id"] ?? ""), rosId: String(m[".id"] ?? ""),
name: (m.name ?? "").trim(), name: (m.name ?? "").trim(),
@@ -540,17 +553,21 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
export type IpsecListResult = IpsecListResponse export type IpsecListResult = IpsecListResponse
export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecListResult> { export async function listIpsec(opts?: { serverId?: string }, dependencies = {
findServer: async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
enabledServers: async () => db.select().from(servers).where(eq(servers.enabled, true)),
readState: fetchIpsecState,
}): Promise<IpsecListResult> {
let serverRows: ServerRow[] let serverRows: ServerRow[]
if (opts?.serverId) { if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10) const id = parseDbServerId(opts.serverId)
if (!Number.isFinite(id)) { if (id === null) {
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] } 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] : [] serverRows = row ? [row] : []
} else { } else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true)) serverRows = await dependencies.enabledServers()
} }
const failures: IpsecListResult["failures"] = [] const failures: IpsecListResult["failures"] = []
@@ -559,7 +576,8 @@ export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecList
await Promise.all( await Promise.all(
serverRows.map(async (server) => { serverRows.map(async (server) => {
try { try {
const state = await fetchIpsecState(server) if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
const state = await dependencies.readState(server)
const serverClients = mapClients(state) const serverClients = mapClients(state)
summaries.push(mapServerSummary(state, serverClients)) summaries.push(mapServerSummary(state, serverClients))
clients.push(...serverClients) clients.push(...serverClients)
@@ -579,10 +597,11 @@ export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecList
export async function countIpsecClients():Promise<number|undefined>{return boundedCompleteCount(()=>listIpsec(),value=>completeCollectionCount(value,"clients"))} export async function countIpsecClients():Promise<number|undefined>{return boundedCompleteCount(()=>listIpsec(),value=>completeCollectionCount(value,"clients"))}
export async function getEnabledIpsecServerById(serverId: string | number) { 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 = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10) const id = parseDbServerId(serverId)
if (!Number.isFinite(id)) return null if (id === null) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null const row = await lookup(id)
return row?.enabled ? row : null
} }
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */ /** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
+21 -20
View File
@@ -1,6 +1,7 @@
import type { MikrotikClient } from "./mikrotik.js" import type { MikrotikClient } from "./mikrotik.js"
import { import {
ipsecManagedComment, ipsecManagedComment,
isManagedIpsecModeConfig,
selectIke2Peers, selectIke2Peers,
selectSharedIke2Identity, selectSharedIke2Identity,
type Ike2IdentityLike, type Ike2IdentityLike,
@@ -21,8 +22,9 @@ export function toRosBody(obj: Record<string, string | number | boolean | undefi
type RosRow = Record<string, string | undefined> type RosRow = Record<string, string | undefined>
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> { async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
const raw = await client.get<unknown>(path).catch(() => []) const raw = await client.get<unknown>(path)
return Array.isArray(raw) ? (raw as RosRow[]) : [] 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 по имени — мастер инициализации идемпотентен. */ /** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
@@ -49,21 +51,20 @@ async function findRosId(client: MikrotikClient, path: string, name: string): Pr
// ── инициализация IKEv2-сервера ───────────────────────────────────────────── // ── инициализация IKEv2-сервера ─────────────────────────────────────────────
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> { export async function ensureIpsecProfile(client: MikrotikClient, name: string, _comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({ await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
name, name,
"hash-algorithm": "sha256", "hash-algorithm": "sha256",
"enc-algorithm": "aes-256,aes-192,aes-128", "enc-algorithm": "aes-256,aes-192,aes-128",
"dh-group": "modp2048,modp1536,modp1024", "dh-group": "modp2048",
"nat-traversal": "yes", "nat-traversal": "yes",
comment,
})) }))
} }
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> { export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({ await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
name, name,
"auth-algorithms": "sha256,sha1", "auth-algorithms": "sha256",
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc", "enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
"pfs-group": "modp2048", "pfs-group": "modp2048",
comment, comment,
@@ -80,13 +81,13 @@ export async function ensureSharedModeConfig(
name: string, name: string,
poolName: string, poolName: string,
dns: string | undefined, dns: string | undefined,
comment: string, _comment: string,
): Promise<void> { ): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({ await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name, name,
"address-pool": poolName, "address-pool": poolName,
responder: "yes",
"static-dns": dns, "static-dns": dns,
comment,
})) }))
} }
@@ -119,7 +120,7 @@ export async function ensurePolicyTemplate(
export async function ensureIpsecPeer( export async function ensureIpsecPeer(
client: MikrotikClient, client: MikrotikClient,
name: string, name: string,
serverCertName: string, _serverCertName: string,
profileName: string, profileName: string,
comment: string, comment: string,
): Promise<void> { ): Promise<void> {
@@ -128,8 +129,6 @@ export async function ensureIpsecPeer(
address: "0.0.0.0/0", address: "0.0.0.0/0",
"exchange-mode": "ike2", "exchange-mode": "ike2",
passive: "yes", passive: "yes",
certificate: serverCertName,
"send-cert": "always",
profile: profileName, profile: profileName,
comment, comment,
})) }))
@@ -157,18 +156,21 @@ export async function putUserModeConfig(
client: MikrotikClient, client: MikrotikClient,
name: string, name: string,
staticIp: string, staticIp: string,
comment: string, _comment: string,
): Promise<void> { ): Promise<void> {
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({ await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
name, name,
address: staticIp, address: staticIp,
comment, responder: "yes",
"address-prefix-length": "32",
})) }))
} }
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> { export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
const id = await findRosId(client, "/ip/ipsec/mode-config", name) const row = (await listByPath(client, "/ip/ipsec/mode-config")).find(row => row.name === name)
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`) 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 { export interface IdentityFields {
@@ -183,22 +185,21 @@ export interface IdentityFields {
/** psk. */ /** psk. */
secret?: string secret?: string
remoteId?: 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 placeBefore?: string
} }
export function identityRosBody(f: IdentityFields): Record<string, string> { export function identityRosBody(f: IdentityFields): Record<string, string> {
return toRosBody({ return toRosBody({
peer: f.peerName, 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, certificate: f.certificate,
"remote-certificate": f.remoteCertificate, "remote-certificate": f.remoteCertificate,
"match-by": f.authMethod === "certificate" ? "certificate" : undefined, "match-by": f.authMethod === "certificate" && f.remoteCertificate ? "certificate" : undefined,
secret: f.secret, 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, "mode-config": f.modeConfig,
"generate-policy": "port-strict", "generate-policy": "port-strict",
"place-before": f.placeBefore,
comment: f.comment, comment: f.comment,
}) })
} }
+78
View File
@@ -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<typeof fetchIpsecState>[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<string, string> }> = []
await ensureIpsecPeer({ get: async () => [], put: async (path: string, body: Record<string, string>) => 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`)
+1 -17
View File
@@ -16,7 +16,6 @@ export type IpsecPeerFormState = {
address: string address: string
exchangeMode: string exchangeMode: string
passive: boolean passive: boolean
certificate: string
profile: string profile: string
disabled: boolean disabled: boolean
} }
@@ -26,7 +25,6 @@ export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({
address: "0.0.0.0/0", address: "0.0.0.0/0",
exchangeMode: "ike2", exchangeMode: "ike2",
passive: true, passive: true,
certificate: "",
profile: "", profile: "",
disabled: false, disabled: false,
}) })
@@ -39,14 +37,12 @@ function IpsecPeerSheet({
onOpenChange, onOpenChange,
busy, busy,
editing, editing,
certificates,
onSubmit, onSubmit,
}: { }: {
open: boolean open: boolean
onOpenChange: (v: boolean) => void onOpenChange: (v: boolean) => void
busy?: boolean busy?: boolean
editing: IpsecPeerDto | null editing: IpsecPeerDto | null
certificates: string[]
onSubmit: (form: IpsecPeerFormState) => void | Promise<void> onSubmit: (form: IpsecPeerFormState) => void | Promise<void>
}) { }) {
const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm) const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm)
@@ -68,7 +64,6 @@ function IpsecPeerSheet({
address: editing.address ?? "", address: editing.address ?? "",
exchangeMode: editing.exchangeMode ?? "ike2", exchangeMode: editing.exchangeMode ?? "ike2",
passive: editing.passive ?? false, passive: editing.passive ?? false,
certificate: editing.certificate ?? "",
profile: editing.profile ?? "", profile: editing.profile ?? "",
disabled: editing.disabled, disabled: editing.disabled,
})) }))
@@ -119,18 +114,7 @@ function IpsecPeerSheet({
))} ))}
</select> </select>
</FormField> </FormField>
<FormField label="Сертификат" hint="Серверный сертификат роутера"> <p className="text-xs text-muted-foreground">Сертификат аутентификации задаётся в identity. Эта форма изменяет только peer.</p>
<Input
className="font-mono"
list="ipsec-peer-certs"
placeholder="ipsec-server"
aria-label="Сертификат" value={form.certificate}
onChange={(e) => set("certificate", e.target.value)}
/>
<datalist id="ipsec-peer-certs">
{certificates.map((c) => <option key={c} value={c} />)}
</datalist>
</FormField>
<FormField label="Profile" hint="Пусто — не менять"> <FormField label="Profile" hint="Пусто — не менять">
<Input className="font-mono" aria-label="Profile" value={form.profile} onChange={(e) => set("profile", e.target.value)} /> <Input className="font-mono" aria-label="Profile" value={form.profile} onChange={(e) => set("profile", e.target.value)} />
</FormField> </FormField>
+7 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5 # 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 ограничения остаются в протоколе. Границы: 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).
+8 -2
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам # План реализации 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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-09 ## Актуальная точка продолжения — 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 и оставшиеся профильные сетевые ограничения открыты. Этапы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 ограничения остаются в протоколе. Границы: 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).
+40 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # 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). Общий план: [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 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 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) ## 12.5.76 — VXLAN: достоверность, экспорт и полная профильная приёмка (2026-10-09)
+39 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 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. План: [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 ограничения остаются в протоколе. Границы: 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 и эти профильные ограничения остаются открытыми.
+8 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-09 # Тестовый сервер 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 | | PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data | | Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска | | Доступ | 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). 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 ограничения остаются в протоколе. Границы: 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).
@@ -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"
}
@@ -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": []
}
@@ -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"
}
}
@@ -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": []
}
]
}
@@ -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"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+3 -3
View File
@@ -27,7 +27,7 @@ for (const device of ["desktop", "mobile"] as const) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }) await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
mkdirSync("docs/ux-ui/screens", { recursive: true }) 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") { if (device === "mobile") {
await page.getByRole("button", { name: "Серверы", exact: true }).click() await page.getByRole("button", { name: "Серверы", exact: true }).click()
await page.getByRole("dialog").getByRole("option").filter({ hasText: "Резервный VPN роутер" }).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") const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Имя клиента" }).fill("Проверенный клиент") await dialog.getByRole("textbox", { name: "Имя клиента" }).fill("Проверенный клиент")
await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().left >= 0 && el.getBoundingClientRect().right <= innerWidth)).toBe(true) 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 dialog.getByRole("button", { name: "Сохранить", exact: true }).focus()
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
@@ -52,6 +52,6 @@ for (const device of ["desktop", "mobile"] as const) {
await page.clock.fastForward(6000) await page.clock.fastForward(6000)
await expect(page.getByText("Клиент обновлён", { exact: true })).toHaveCount(0) await expect(page.getByText("Клиент обновлён", { exact: true })).toHaveCount(0)
if (device === "mobile") await page.getByRole("button", { name: "Переинициализировать", exact: true }).scrollIntoViewIfNeeded() 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" })
}) })
} }
+28
View File
@@ -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()
})