fix(ipsec): подтвердить достоверность операций и приёмку раздела
This commit is contained in:
1 parent
e2d81f9657
commit
f80d9b7637
28 files changed
+969
-170
No files matched your search
+30
-24
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import type { Server } from "@/lib/data"
|
||||
@@ -174,7 +175,7 @@ const ipsecInventorySchema = z.array(z.object({
|
||||
latency: z.number().nullable().optional(), asn: z.string().optional(),
|
||||
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
|
||||
|
||||
function IpsecPage() {
|
||||
function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWorkspaceTab }: { selectedServerId: string; setSelectedServerId: (id: string) => void; workspaceTab: IpsecWorkspaceTab; setWorkspaceTab: (tab: IpsecWorkspaceTab) => void }) {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
@@ -201,10 +202,9 @@ function IpsecPage() {
|
||||
const certPending = useRef(false)
|
||||
useEffect(() => () => { certEpoch.current += 1 }, [])
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
|
||||
const [search, setSearch] = useState("")
|
||||
const [initOpen, setInitOpen] = useState(false)
|
||||
const [initTargetId, setInitTargetId] = useState<string | undefined>()
|
||||
const [userOpen, setUserOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
|
||||
const [certOpen, setCertOpen] = useState(false)
|
||||
@@ -265,10 +265,8 @@ function IpsecPage() {
|
||||
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
|
||||
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
|
||||
|
||||
const effectiveServerId =
|
||||
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||
? selectedServerId
|
||||
: ALL_SERVERS_ID
|
||||
const effectiveServerId = selectedServerId
|
||||
const missingSelectedServer = selectedServerId !== ALL_SERVERS_ID && hasLoaded && !displayServers.some(s => s.id === selectedServerId)
|
||||
|
||||
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||||
|
||||
@@ -315,7 +313,7 @@ function IpsecPage() {
|
||||
}
|
||||
|
||||
const failedServerIds = new Set(failures.map(f => f.serverId))
|
||||
const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
|
||||
const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
|
||||
|
||||
const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
|
||||
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
|
||||
@@ -337,7 +335,7 @@ function IpsecPage() {
|
||||
operationLock.current = false
|
||||
if (mounted.current) setBusy(false)
|
||||
}
|
||||
const hasReadyDevice = displayServers.some(s => canManage(s.id))
|
||||
const hasReadyDevice = !missingSelectedServer && displayServers.some(s => (effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId) && canManage(s.id))
|
||||
|
||||
const scopedClients = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
|
||||
@@ -366,10 +364,6 @@ function IpsecPage() {
|
||||
const caOk = scopedSummaries.some((s) => s.caCert)
|
||||
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||||
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||||
const certNames = useMemo(
|
||||
() => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(),
|
||||
[scopedSummaries],
|
||||
)
|
||||
const peerOptions = useMemo(
|
||||
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
|
||||
[scopedSummaries],
|
||||
@@ -583,7 +577,6 @@ function IpsecPage() {
|
||||
address: form.address.trim() || undefined,
|
||||
exchangeMode: form.exchangeMode.trim() || undefined,
|
||||
passive: form.passive,
|
||||
certificate: form.certificate.trim() || undefined,
|
||||
profile: form.profile.trim() || undefined,
|
||||
disabled: form.disabled,
|
||||
})
|
||||
@@ -626,6 +619,7 @@ function IpsecPage() {
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
preserveSelection
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
@@ -675,6 +669,7 @@ function IpsecPage() {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{missingSelectedServer && <Alert variant="destructive" role="alert"><AlertTitle>Устройство из ссылки не найдено</AlertTitle><AlertDescription>Выберите доступный сервер. Данные других устройств не подставляются в этот срез.</AlertDescription></Alert>}
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка IPsec"
|
||||
items={[
|
||||
@@ -799,7 +794,7 @@ function IpsecPage() {
|
||||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
|
||||
servers={scopedSummaries}
|
||||
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}
|
||||
onEditPeer={isLive ? openEditPeer : undefined}
|
||||
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
||||
@@ -831,12 +826,10 @@ function IpsecPage() {
|
||||
lines: [
|
||||
"/ip ipsec peer add \\",
|
||||
" name=ipsec-vpn address=0.0.0.0/0 \\",
|
||||
" exchange-mode=ike2 passive=yes \\",
|
||||
" certificate=ipsec-server send-cert=always",
|
||||
" exchange-mode=ike2 passive=yes profile=ipsec-vpn",
|
||||
"/ip ipsec identity add \\",
|
||||
" peer=ipsec-vpn auth-method=rsa-key \\",
|
||||
" peer=ipsec-vpn auth-method=digital-signature \\",
|
||||
" certificate=ipsec-server \\",
|
||||
" match-by=certificate \\",
|
||||
" generate-policy=port-strict \\",
|
||||
" mode-config=ipsec-vpn",
|
||||
],
|
||||
@@ -892,7 +885,6 @@ function IpsecPage() {
|
||||
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
|
||||
busy={busy}
|
||||
editing={editingPeer?.peer ?? null}
|
||||
certificates={certNames}
|
||||
onSubmit={handleEditPeer}
|
||||
/>
|
||||
|
||||
@@ -901,7 +893,7 @@ function IpsecPage() {
|
||||
onOpenChange={setInitOpen}
|
||||
servers={serverOptions.filter(s => canManage(s.id) || busy)}
|
||||
busy={busy}
|
||||
defaultServerId={sheetServerId}
|
||||
defaultServerId={initTargetId ?? sheetServerId}
|
||||
onSubmit={handleInit}
|
||||
/>
|
||||
|
||||
@@ -959,7 +951,21 @@ function IpsecPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default function IpsecSource() {
|
||||
function IpsecRoute() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
return <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>
|
||||
}
|
||||
@@ -28,8 +28,9 @@
|
||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
|
||||
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
|
||||
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts",
|
||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/routes/vxlan.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
|
||||
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database",
|
||||
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database",
|
||||
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
|
||||
|
||||
+37
-53
@@ -14,7 +14,6 @@ import {
|
||||
} from "@mmapp/contracts/ipsec"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import {
|
||||
IPSEC_CA_CERT,
|
||||
IPSEC_COMMON_NAME,
|
||||
IPSEC_SERVER_CERT,
|
||||
buildClientInstructions,
|
||||
@@ -49,7 +48,6 @@ import {
|
||||
patchPeer,
|
||||
deletePeer,
|
||||
ensureSharedIke2Identity,
|
||||
listByPath,
|
||||
} from "../services/ipsec-ros.js"
|
||||
import {
|
||||
ensureCaCertificate,
|
||||
@@ -65,6 +63,7 @@ import {
|
||||
getEnabledIpsecServerById,
|
||||
listIpsec,
|
||||
mapClients,
|
||||
resolveIpsecClientTarget,
|
||||
mapServerSummary,
|
||||
} from "../services/ipsec-live.js"
|
||||
import {
|
||||
@@ -75,11 +74,12 @@ import {
|
||||
} from "../services/config-revisions.js"
|
||||
import { parseIpsecSnapshot, planIpsecRestore } from "../services/entity-snapshots.js"
|
||||
import { executeRosOps } from "../services/ros-ops.js"
|
||||
import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js"
|
||||
import { parseDbServerId } from "../utils/server-id.js"
|
||||
import { isIP } from "node:net"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { z } from "zod"
|
||||
|
||||
const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
|
||||
|
||||
function generatePassphrase(): string {
|
||||
return randomBytes(9).toString("base64url")
|
||||
@@ -174,7 +174,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const sid = parseDbServerId(q.serverId)
|
||||
if (sid !== null) {
|
||||
const server = await getEnabledIpsecServerById(sid)
|
||||
if (server) await recordIpsec(server, "observed")
|
||||
if (server && !result.failures?.some(f => f.serverId === String(sid))) await recordIpsec(server, "observed")
|
||||
}
|
||||
return reply.send(result)
|
||||
})
|
||||
@@ -206,6 +206,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
|
||||
await ensurePolicyTemplate(client, body.poolCidr, IPSEC_COMMON_NAME, comment)
|
||||
await ensureIpsecPeer(client, IPSEC_COMMON_NAME, serverCert, IPSEC_COMMON_NAME, comment)
|
||||
await ensureSharedIke2Identity(client, { peerName: IPSEC_COMMON_NAME, serverCertName: serverCert, modeConfigName: IPSEC_COMMON_NAME, comment: ipsecManagedComment("IKEv2 listener") })
|
||||
if (body.createNatRule) {
|
||||
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
|
||||
}
|
||||
@@ -225,31 +226,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
for (const path of ["/ip/ipsec/identity", "/ip/ipsec/mode-config", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/pool"]) {
|
||||
const rows = await listByPath(client, path)
|
||||
for (const row of rows) {
|
||||
if (!row[".id"]) continue
|
||||
if (isIpsecManagedComment(row.comment) || (path === "/ip/ipsec/policy" && (row.template === "true" || row.template === "yes"))) {
|
||||
await client.delete(`${path}/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
const nat = await listByPath(client, "/ip/firewall/nat")
|
||||
for (const row of nat) {
|
||||
if (row[".id"] && isIpsecManagedComment(row.comment)) {
|
||||
await client.delete(`/ip/firewall/nat/${encodeURIComponent(row[".id"])}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
// profile/proposal не критично оставлять; сертификаты — по флагу
|
||||
if (removeCertificates) {
|
||||
const certs = await client.getCertificates()
|
||||
for (const c of certs) {
|
||||
const name = String(c.name ?? "")
|
||||
if (c[".id"] && (name === IPSEC_CA_CERT || name === IPSEC_SERVER_CERT || name.startsWith("ipsec-user-"))) {
|
||||
await client.delete(`/certificate/${encodeURIComponent(c[".id"])}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
await removeManagedIpsecServer(client, removeCertificates)
|
||||
await recordIpsec(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
@@ -275,7 +252,6 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
?? ike2Peers.find((p) => isIpsecManagedComment(p.comment))
|
||||
?? ike2Peers.find((p) => (p.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
?? ike2Peers[0]
|
||||
?? state.peers[0]
|
||||
if (!peer) {
|
||||
return reply.status(400).send({ error: "На роутере нет ни одного IPsec peer — создайте peer на вкладке «Сервер»" })
|
||||
}
|
||||
@@ -288,13 +264,15 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
.find((n) => n && !n.startsWith("mc-ipsec-"))
|
||||
const sharedMc = (idMcName ? state.modeConfigs.find((m) => (m.name ?? "").trim() === idMcName) : undefined)
|
||||
?? state.modeConfigs.find((m) => (m.name ?? "").trim() === IPSEC_COMMON_NAME)
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
|
||||
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
||||
if (body.authMethod === "certificate" && (!caName || !serverCertRow)) {
|
||||
return reply.status(400).send({
|
||||
error: "Нет CA/серверного сертификата — для сертификатного клиента запустите мастер или используйте PSK",
|
||||
})
|
||||
}
|
||||
if (body.peerName && (peer.name ?? "").trim() !== body.peerName.trim()) return reply.status(404).send({ error: "Выбранный peer не найден" })
|
||||
if (mapClients(state).some(c => c.name === body.name.trim()) || (body.authMethod === "certificate" && state.certs.some(c => c.name === clientCertName(body.name)))) return reply.status(409).send({ error: "Клиент уже существует" })
|
||||
const peerName = (peer.name ?? "").trim()
|
||||
const serverEndpoint = String(serverCertRow?.["common-name"] ?? "").trim() || server.host
|
||||
const dns = sharedMc?.["static-dns"]?.trim() || undefined
|
||||
@@ -309,7 +287,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
let modeConfig = (sharedMc?.name ?? "").trim()
|
||||
if (body.staticIp) {
|
||||
const ip = body.staticIp.trim()
|
||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
const taken = takenStaticIps(state.modeConfigs)
|
||||
if (taken.includes(ip)) {
|
||||
return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||
@@ -386,22 +364,25 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const d = parsed.data
|
||||
if ("staticIp" in d && d.staticIp && isIP(d.staticIp.trim()) !== 4) return reply.status(400).send({ error: "Некорректный статический IPv4 адрес" })
|
||||
const server = await getEnabledIpsecServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const targetRosId = rosIdParam(rosId)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
|
||||
const certRow = identity
|
||||
? undefined
|
||||
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
|
||||
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
|
||||
const target = resolveIpsecClientTarget(state, targetRosId)
|
||||
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
|
||||
let { identity, certRow } = target
|
||||
if (certRow) {
|
||||
identity = state.identities.find(i => i["remote-certificate"] === certRow?.name)
|
||||
if (identity) certRow = undefined
|
||||
}
|
||||
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
|
||||
const ike2Peers = selectIke2Peers(state.peers)
|
||||
const ike2PeerNames = ike2Peers.map((p) => (p.name ?? "").trim()).filter(Boolean)
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
|
||||
const managedIdentity = identity
|
||||
? isIpsecManagedComment(identity.comment)
|
||||
: String(certRow?.name ?? "").startsWith("ipsec-user-")
|
||||
@@ -419,9 +400,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
comment: managedIdentity ? ipsecUserComment(d.name) : d.name.trim(),
|
||||
})
|
||||
}
|
||||
// managed-серт переименовываем на конвенционное имя (client1/anakondra на устройстве не трогаем)
|
||||
if (certRow?.[".id"] && managedIdentity) {
|
||||
await client.patch(`/certificate/${encodeURIComponent(String(certRow[".id"]))}`, { name: clientCertName(d.name) })
|
||||
// Preserve the signed certificate CN/key/name; keep the editable display name in an identity.
|
||||
if (certRow && !d.staticIp) {
|
||||
const peer = ike2Peers.find(p => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
|
||||
if (!peer || !serverCertRow) return reply.status(400).send({ error: "Нет IKEv2 peer или серверного сертификата" })
|
||||
await putIdentity(client, { peerName: peer.name ?? "", modeConfig: sharedMcName, comment: ipsecUserComment(d.name), authMethod: "certificate", certificate: serverCertRow.name, remoteCertificate: certRow.name })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,7 +417,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (personal) await deleteUserModeConfig(client, personal)
|
||||
} else {
|
||||
const ip = d.staticIp.trim()
|
||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
const others = takenStaticIps(
|
||||
state.modeConfigs.filter((m) => (m.name ?? "").trim() !== currentMc),
|
||||
)
|
||||
@@ -446,7 +429,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
} else if (!identity && certRow && d.staticIp) {
|
||||
// cert-клиент без identity: персональная identity (match-by=certificate) + статический mode-config
|
||||
const ip = d.staticIp.trim()
|
||||
if (!IPV4_RE.test(ip)) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
if (isIP(ip) !== 4) return reply.status(400).send({ error: `Некорректный IP: ${ip}` })
|
||||
const taken = takenStaticIps(state.modeConfigs)
|
||||
if (taken.includes(ip)) return reply.status(409).send({ error: `IP ${ip} уже назначен другому клиенту` })
|
||||
const peer = ike2Peers.find((p) => isIpsecManagedComment(p.comment)) ?? ike2Peers[0]
|
||||
@@ -491,11 +474,10 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const targetRosId = rosIdParam(rosId)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === targetRosId)
|
||||
const certRow = identity
|
||||
? undefined
|
||||
: state.certs.find((c) => String(c[".id"] ?? "") === targetRosId)
|
||||
?? state.certs.find((c) => String(c.name ?? "").trim() === targetRosId)
|
||||
const target = resolveIpsecClientTarget(state, targetRosId)
|
||||
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
|
||||
const identity = target.identity ?? (target.certRow ? state.identities.find(i => i["remote-certificate"] === target.certRow?.name) : undefined)
|
||||
const certRow = identity ? undefined : target.certRow
|
||||
if (!identity && !certRow) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const managed = identity
|
||||
? isIpsecManagedComment(identity.comment)
|
||||
@@ -514,11 +496,11 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (removeCertificate) {
|
||||
// удаляем только клиентский серт (managed / выбранный явно); CA и серверный не трогаем
|
||||
const ike2Peers = selectIke2Peers(state.peers)
|
||||
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
|
||||
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers)?.name ?? "").trim()
|
||||
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities))
|
||||
const serverCertName = String(resolveIke2ServerCert(state.certs, ike2Peers, state.identities)?.name ?? "").trim()
|
||||
const certName = remoteCert || (managed && userName ? clientCertName(userName) : "")
|
||||
if (certName && certName !== caName && certName !== serverCertName) {
|
||||
await client.removeCertificate(certName).catch(() => undefined)
|
||||
await client.removeCertificate(certName)
|
||||
}
|
||||
}
|
||||
await recordIpsec(server, "apply")
|
||||
@@ -547,7 +529,7 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (d.address !== undefined) body.address = d.address
|
||||
if (d.exchangeMode !== undefined) body["exchange-mode"] = d.exchangeMode
|
||||
if (d.passive !== undefined) body.passive = d.passive ? "yes" : "no"
|
||||
if (d.certificate !== undefined) body.certificate = d.certificate
|
||||
if (d.certificate !== undefined) return reply.status(400).send({ error: "Сертификат задаётся в IPsec identity, а не в peer" })
|
||||
if (d.profile !== undefined) body.profile = d.profile
|
||||
if (d.disabled !== undefined) body.disabled = d.disabled ? "yes" : "no"
|
||||
if (Object.keys(body).length) await patchPeer(client, rosIdParam(rosId), body)
|
||||
@@ -633,7 +615,9 @@ const ipsecRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
const identity = state.identities.find((i) => String(i[".id"] ?? "") === rosIdParam(rosId))
|
||||
const target = resolveIpsecClientTarget(state, rosIdParam(rosId))
|
||||
if (target.ambiguous) return reply.status(409).send({ error: "Неоднозначный ID клиента. Обновите список IPsec" })
|
||||
const identity = target.identity
|
||||
if (!identity) return reply.status(404).send({ error: "Клиент не найден" })
|
||||
const userName = parseIpsecUserComment(identity.comment) ?? ""
|
||||
if (!userName) return reply.status(400).send({ error: "Не managed-клиент" })
|
||||
|
||||
@@ -693,8 +693,6 @@ function ipsecPeerBody(p: IpsecSnapshotPeer): Record<string, string> {
|
||||
address: p.address,
|
||||
"exchange-mode": p.exchangeMode,
|
||||
passive: rosYesNo(p.passive),
|
||||
certificate: p.certificate,
|
||||
"send-cert": "always",
|
||||
profile: p.profile,
|
||||
comment: p.comment,
|
||||
disabled: rosYesNo(p.disabled),
|
||||
@@ -723,7 +721,6 @@ function ipsecModeConfigBody(m: IpsecSnapshotModeConfig): Record<string, string>
|
||||
"address-pool": m.addressPool,
|
||||
address: m.address,
|
||||
"static-dns": m.staticDns,
|
||||
comment: m.comment,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export async function ensureCaCertificate(client: MikrotikClient, daysValid: num
|
||||
await client.signCertificate({ name: IPSEC_CA_CERT, daysValid })
|
||||
await waitCertificateSigned(client, IPSEC_CA_CERT)
|
||||
}
|
||||
const ca = await findCertificate(client, IPSEC_CA_CERT)
|
||||
if (ca?.[".id"] && !["true", "yes"].includes(ca.trusted ?? "")) await client.patch(`/certificate/${encodeURIComponent(ca[".id"])}`, { trusted: "yes" })
|
||||
return IPSEC_CA_CERT
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import { identityRosBody } from "./ipsec-ros.js"
|
||||
assert.ok(!isIpsecManagedComment(undefined))
|
||||
assert.equal(ipsecUserComment("alice"), `${IPSEC_MARKER} user=alice`)
|
||||
assert.equal(parseIpsecUserComment(ipsecUserComment("alice")), "alice")
|
||||
assert.equal(parseIpsecUserComment(ipsecUserComment("Alice Cooper")), "Alice Cooper")
|
||||
assert.equal(parseIpsecUserComment("foreign user=alice"), null)
|
||||
assert.equal(parseIpsecUserComment("не managed"), null)
|
||||
assert.equal(parseIpsecUserComment(null), null)
|
||||
}
|
||||
@@ -187,7 +189,8 @@ import { identityRosBody } from "./ipsec-ros.js"
|
||||
remoteCertificate: "ipsec-user-alice",
|
||||
placeBefore: "*S",
|
||||
})
|
||||
assert.equal(body["place-before"], "*S")
|
||||
assert.equal(body["auth-method"], "digital-signature")
|
||||
assert.equal(body["place-before"], undefined)
|
||||
assert.equal(body["remote-certificate"], "ipsec-user-alice")
|
||||
assert.equal(body["match-by"], "certificate")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ export function ipsecManagedComment(label = ""): string {
|
||||
}
|
||||
|
||||
export function isIpsecManagedComment(comment: string | undefined | null): boolean {
|
||||
return Boolean(comment) && String(comment).trim().startsWith(IPSEC_MARKER)
|
||||
return Boolean(comment) && (String(comment).trim() === IPSEC_MARKER || String(comment).trim().startsWith(`${IPSEC_MARKER} `))
|
||||
}
|
||||
|
||||
/** Reserved RouterOS names identify mode-configs on versions without a comment property. */
|
||||
export function isManagedIpsecModeConfig(row: { name?: string; comment?: string }): boolean {
|
||||
return isIpsecManagedComment(row.comment) || row.name === "ipsec-vpn" || Boolean(row.name?.startsWith("mc-ipsec-"))
|
||||
}
|
||||
|
||||
/** Комментарий identity клиента: `MikrotikManager:ipsec user=<имя>`. */
|
||||
@@ -20,8 +25,8 @@ export function ipsecUserComment(name: string): string {
|
||||
}
|
||||
|
||||
export function parseIpsecUserComment(comment: string | undefined | null): string | null {
|
||||
if (!comment) return null
|
||||
const m = /user=([^\s]+)/.exec(comment.trim())
|
||||
if (!isIpsecManagedComment(comment)) return null
|
||||
const m = /(?:^|\s)user=(.+)$/.exec(comment!.trim())
|
||||
return m?.[1] ?? null
|
||||
}
|
||||
|
||||
@@ -150,7 +155,7 @@ export function isIke2RemoteAccessIdentity(
|
||||
if (names.size > 0 && !names.has((i.peer ?? "").trim())) return false
|
||||
const hasModeConfig = Boolean((i["mode-config"] ?? "").trim())
|
||||
const hasRemoteCert = Boolean((i["remote-certificate"] ?? "").trim())
|
||||
const rsaKey = (i["auth-method"] ?? "").trim().toLowerCase() === "rsa-key"
|
||||
const rsaKey = ["rsa-key", "digital-signature"].includes((i["auth-method"] ?? "").trim().toLowerCase())
|
||||
return hasModeConfig || hasRemoteCert || (rsaKey && Boolean((i.certificate ?? "").trim()))
|
||||
}
|
||||
|
||||
@@ -176,8 +181,9 @@ export function selectSharedIke2Identity<T extends Ike2IdentityLike>(
|
||||
}
|
||||
|
||||
/** Серверный серт IKEv2 — из `peer.certificate` (напр. vpn-server), fallback по имени/usage. */
|
||||
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[]): T | undefined {
|
||||
const names = new Set(ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean))
|
||||
export function resolveIke2ServerCert<T extends RosRow>(certs: T[], ike2Peers: PeerLike[], identities: Ike2IdentityLike[] = []): T | undefined {
|
||||
const peerNames = new Set(ike2Peers.map(p => p.name))
|
||||
const names = new Set([...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...identities.filter(i => peerNames.has(i.peer)).map(i => (i.certificate ?? "").trim())].filter(Boolean))
|
||||
return certs.find((c) => names.has(String(c.name ?? "").trim()))
|
||||
?? certs.find((c) => String(c.name ?? "").trim() === IPSEC_SERVER_CERT)
|
||||
?? certs.find((c) => String(c["key-usage"] ?? "").includes("tls-server"))
|
||||
@@ -297,7 +303,7 @@ export function buildClientInstructions(args: {
|
||||
`1. Скачайте и импортируйте сертификат ${args.p12Filename}.`,
|
||||
pass ? ` ${pass.trim()}` : " Пароль архива задаётся при экспорте.",
|
||||
"2. Windows: Параметры → Сеть → VPN → Добавить: тип IKEv2, «Вход с сертификатом»",
|
||||
" (сертификат из .p12 должен лежать в хранилище «Личный» текущего пользователя).",
|
||||
" Импортируйте .p12 в «Локальный компьютер» → «Личный», CA — в доверенные корневые сертификаты.",
|
||||
"3. macOS/iOS: импортируйте .p12 в Связку ключей, затем добавьте VPN (IKEv2),",
|
||||
" аутентификация — сертификат; удалённый ID = CN серверного сертификата.",
|
||||
"4. Android: strongSwan app → импорт .sswan-профиля (сертификат уже внутри).",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
|
||||
import { parseDbServerId } from "../utils/server-id.js"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
identityDisplayName,
|
||||
isIke2RemoteAccessIdentity,
|
||||
isIpsecManagedComment,
|
||||
isManagedIpsecModeConfig,
|
||||
resolveIke2CaName,
|
||||
resolveIke2ServerCert,
|
||||
selectIke2Peers,
|
||||
@@ -196,18 +198,19 @@ export interface IpsecServerState {
|
||||
certs: RosCertRow[]
|
||||
}
|
||||
|
||||
export async function fetchIpsecState(server: ServerRow): Promise<IpsecServerState> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const empty = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
|
||||
export async function fetchIpsecState(server: ServerRow, reader?: MikrotikClient): Promise<IpsecServerState> {
|
||||
if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
|
||||
const client = reader ?? MikrotikClient.fromServer(server)
|
||||
const collection = async <T>(path: string): Promise<T[]> => {
|
||||
const raw = await client.get<unknown>(path)
|
||||
if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || typeof row[".id"] !== "string" || !row[".id"])) throw new Error(`Некорректный ответ IPsec: ${path}`)
|
||||
return raw as T[]
|
||||
}
|
||||
const [peers, identities, modeConfigs, pools, policies, nat, active, certs] = await Promise.all([
|
||||
client.get<unknown>("/ip/ipsec/peer").then((v) => empty<RosIpsecPeer>(v)).catch(() => [] as RosIpsecPeer[]),
|
||||
client.get<unknown>("/ip/ipsec/identity").then((v) => empty<RosIpsecIdentity>(v)).catch(() => [] as RosIpsecIdentity[]),
|
||||
client.get<unknown>("/ip/ipsec/mode-config").then((v) => empty<RosIpsecModeConfig>(v)).catch(() => [] as RosIpsecModeConfig[]),
|
||||
client.get<unknown>("/ip/pool").then((v) => empty<RosIpsecPool>(v)).catch(() => [] as RosIpsecPool[]),
|
||||
client.get<unknown>("/ip/ipsec/policy").then((v) => empty<RosIpsecPolicy>(v)).catch(() => [] as RosIpsecPolicy[]),
|
||||
client.get<unknown>("/ip/firewall/nat").then((v) => empty<RosFirewallNat>(v)).catch(() => [] as RosFirewallNat[]),
|
||||
client.get<unknown>("/ip/ipsec/active-peers").then((v) => empty<RosIpsecActivePeer>(v)).catch(() => [] as RosIpsecActivePeer[]),
|
||||
client.getCertificates().catch(() => [] as RosCertRow[]),
|
||||
collection<RosIpsecPeer>("/ip/ipsec/peer"), collection<RosIpsecIdentity>("/ip/ipsec/identity"),
|
||||
collection<RosIpsecModeConfig>("/ip/ipsec/mode-config"), collection<RosIpsecPool>("/ip/pool"),
|
||||
collection<RosIpsecPolicy>("/ip/ipsec/policy"), collection<RosFirewallNat>("/ip/firewall/nat"),
|
||||
collection<RosIpsecActivePeer>("/ip/ipsec/active-peers"), collection<RosCertRow>("/certificate"),
|
||||
])
|
||||
return { server, client, peers, identities, modeConfigs, pools, policies, nat, active, certs }
|
||||
}
|
||||
@@ -226,10 +229,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
const serverName = String(server.name ?? "").trim() || String(server.host ?? server.id)
|
||||
|
||||
const roleCtx: CertificateRoleContext = {
|
||||
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
|
||||
peerCertNames: [...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...state.identities.filter(i => ike2PeerNames.includes((i.peer ?? "").trim())).map(i => (i.certificate ?? "").trim())].filter(Boolean),
|
||||
identityCertNames: state.identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
||||
}
|
||||
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers))
|
||||
const caName = resolveIke2CaName(state.certs, resolveIke2ServerCert(state.certs, ike2Peers, state.identities))
|
||||
const shared = selectSharedIke2Identity(state.identities, ike2PeerNames)
|
||||
const sharedRosId = shared?.[".id"] ? String(shared[".id"]) : undefined
|
||||
|
||||
@@ -270,11 +273,11 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
const active = (cn ? activeByRemote.get(cn) : undefined)
|
||||
?? (identity?.["remote-id"] ? activeByRemote.get(String(identity["remote-id"]).trim()) : undefined)
|
||||
out.push({
|
||||
id: `${server.id}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
rosId: String(identity?.[".id"] ?? cert[".id"] ?? certName),
|
||||
id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`,
|
||||
serverId: String(server.id),
|
||||
serverName,
|
||||
name: cn || certName,
|
||||
name: identity ? identityDisplayName(identity, state.certs) : (cn || certName),
|
||||
authMethod: "certificate",
|
||||
kind: "cert",
|
||||
certificateName: certName,
|
||||
@@ -305,8 +308,8 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
const remoteId = (i["remote-id"] ?? "").trim()
|
||||
const active = remoteId ? activeByRemote.get(remoteId) : undefined
|
||||
out.push({
|
||||
id: `${server.id}:${rosId || "identity"}`,
|
||||
rosId: rosId || "identity",
|
||||
id: `${server.id}:identity:${rosId}`,
|
||||
rosId: `identity:${rosId}`,
|
||||
serverId: String(server.id),
|
||||
serverName,
|
||||
name: identityDisplayName(i, state.certs),
|
||||
@@ -328,6 +331,16 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] {
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** Explicit namespaces prevent cross-menu RouterOS ID collisions. Legacy ambiguous IDs fail closed. */
|
||||
export function resolveIpsecClientTarget(state: IpsecServerState, token: string) {
|
||||
const kind = token.startsWith("cert:") ? "cert" : token.startsWith("identity:") ? "identity" : undefined
|
||||
const raw = kind ? token.slice(kind.length + 1) : token
|
||||
const clients = mapClients(state)
|
||||
const identity = kind === "cert" ? undefined : state.identities.find(i => i[".id"] === raw && clients.some(c => c.rosId === "identity:" + raw))
|
||||
const certRow = kind === "identity" ? undefined : state.certs.find(c => (c[".id"] === raw || c.name === raw) && clients.some(user => user.kind === "cert" && user.certName === c.name))
|
||||
return { identity, certRow, ambiguous: Boolean(!kind && identity && certRow) }
|
||||
}
|
||||
|
||||
export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto {
|
||||
const server = state.server
|
||||
const ike2Peers = selectIke2Peers(state.peers)
|
||||
@@ -353,12 +366,12 @@ export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDt
|
||||
?? state.pools.find((p) => isIpsecManagedComment(p.comment))
|
||||
: undefined
|
||||
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers)
|
||||
const serverCertRow = resolveIke2ServerCert(state.certs, ike2Peers, state.identities)
|
||||
const caName = resolveIke2CaName(state.certs, serverCertRow)
|
||||
const caCertRow = caName ? state.certs.find((c) => String(c.name ?? "").trim() === caName) : undefined
|
||||
|
||||
const roleCtx: CertificateRoleContext = {
|
||||
peerCertNames: ike2Peers.map((p) => (p.certificate ?? "").trim()).filter(Boolean),
|
||||
peerCertNames: [...ike2Peers.map((p) => (p.certificate ?? "").trim()), ...state.identities.filter(i => ike2PeerNames.includes((i.peer ?? "").trim())).map(i => (i.certificate ?? "").trim())].filter(Boolean),
|
||||
identityCertNames: ike2Identities.map((i) => (i["remote-certificate"] ?? "").trim()).filter(Boolean),
|
||||
}
|
||||
const referencedCertNames = new Set<string>([
|
||||
@@ -432,7 +445,7 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
|
||||
disabled: asBool(i.disabled),
|
||||
})),
|
||||
modeConfigs: state.modeConfigs
|
||||
.filter((m) => isIpsecManagedComment(m.comment))
|
||||
.filter(isManagedIpsecModeConfig)
|
||||
.map((m) => ({
|
||||
name: (m.name ?? "").trim(),
|
||||
addressPool: (m["address-pool"] ?? "").trim(),
|
||||
@@ -496,7 +509,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
|
||||
.map((i) => ({
|
||||
rosId: String(i[".id"] ?? ""),
|
||||
peerName: (i.peer ?? "").trim(),
|
||||
authMethod: (i["auth-method"] ?? "") === "pre-shared-key" ? "pre-shared-key" as const : "rsa-key" as const,
|
||||
authMethod: (i["auth-method"] ?? "").trim(),
|
||||
certificate: (i.certificate ?? "").trim(),
|
||||
remoteCertificate: (i["remote-certificate"] ?? "").trim(),
|
||||
matchBy: (i["match-by"] ?? "").trim(),
|
||||
@@ -508,7 +521,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
|
||||
disabled: asBool(i.disabled),
|
||||
})),
|
||||
modeConfigs: state.modeConfigs
|
||||
.filter((m) => isIpsecManagedComment(m.comment))
|
||||
.filter(isManagedIpsecModeConfig)
|
||||
.map((m) => ({
|
||||
rosId: String(m[".id"] ?? ""),
|
||||
name: (m.name ?? "").trim(),
|
||||
@@ -540,17 +553,21 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
|
||||
|
||||
export type IpsecListResult = IpsecListResponse
|
||||
|
||||
export async function listIpsec(opts?: { serverId?: string }): Promise<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[]
|
||||
if (opts?.serverId) {
|
||||
const id = Number.parseInt(String(opts.serverId), 10)
|
||||
if (!Number.isFinite(id)) {
|
||||
const id = parseDbServerId(opts.serverId)
|
||||
if (id === null) {
|
||||
return { servers: [], clients: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
|
||||
}
|
||||
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
|
||||
const row = await dependencies.findServer(id)
|
||||
serverRows = row ? [row] : []
|
||||
} else {
|
||||
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
serverRows = await dependencies.enabledServers()
|
||||
}
|
||||
|
||||
const failures: IpsecListResult["failures"] = []
|
||||
@@ -559,7 +576,8 @@ export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecList
|
||||
await Promise.all(
|
||||
serverRows.map(async (server) => {
|
||||
try {
|
||||
const state = await fetchIpsecState(server)
|
||||
if (!server.enabled) throw new Error("Сервер выключен. Опрос IPsec не выполнялся")
|
||||
const state = await dependencies.readState(server)
|
||||
const serverClients = mapClients(state)
|
||||
summaries.push(mapServerSummary(state, serverClients))
|
||||
clients.push(...serverClients)
|
||||
@@ -579,10 +597,11 @@ export async function listIpsec(opts?: { serverId?: string }): Promise<IpsecList
|
||||
|
||||
export async function countIpsecClients():Promise<number|undefined>{return boundedCompleteCount(()=>listIpsec(),value=>completeCollectionCount(value,"clients"))}
|
||||
|
||||
export async function getEnabledIpsecServerById(serverId: string | number) {
|
||||
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
|
||||
if (!Number.isFinite(id)) return null
|
||||
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
||||
export async function getEnabledIpsecServerById(serverId: string | number, lookup = async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]) {
|
||||
const id = parseDbServerId(serverId)
|
||||
if (id === null) return null
|
||||
const row = await lookup(id)
|
||||
return row?.enabled ? row : null
|
||||
}
|
||||
|
||||
/** Каталог клиентов для модуля «Пользователи» (привязка app-пользователей по CN сертификата). */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
import {
|
||||
ipsecManagedComment,
|
||||
isManagedIpsecModeConfig,
|
||||
selectIke2Peers,
|
||||
selectSharedIke2Identity,
|
||||
type Ike2IdentityLike,
|
||||
@@ -21,8 +22,9 @@ export function toRosBody(obj: Record<string, string | number | boolean | undefi
|
||||
type RosRow = Record<string, string | undefined>
|
||||
|
||||
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
|
||||
const raw = await client.get<unknown>(path).catch(() => [])
|
||||
return Array.isArray(raw) ? (raw as RosRow[]) : []
|
||||
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 RosRow[]
|
||||
}
|
||||
|
||||
/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
|
||||
@@ -49,21 +51,20 @@ async function findRosId(client: MikrotikClient, path: string, name: string): Pr
|
||||
|
||||
// ── инициализация IKEv2-сервера ─────────────────────────────────────────────
|
||||
|
||||
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> {
|
||||
export async function ensureIpsecProfile(client: MikrotikClient, name: string, _comment: string): Promise<void> {
|
||||
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
|
||||
name,
|
||||
"hash-algorithm": "sha256",
|
||||
"enc-algorithm": "aes-256,aes-192,aes-128",
|
||||
"dh-group": "modp2048,modp1536,modp1024",
|
||||
"dh-group": "modp2048",
|
||||
"nat-traversal": "yes",
|
||||
comment,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
|
||||
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
|
||||
name,
|
||||
"auth-algorithms": "sha256,sha1",
|
||||
"auth-algorithms": "sha256",
|
||||
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
|
||||
"pfs-group": "modp2048",
|
||||
comment,
|
||||
@@ -80,13 +81,13 @@ export async function ensureSharedModeConfig(
|
||||
name: string,
|
||||
poolName: string,
|
||||
dns: string | undefined,
|
||||
comment: string,
|
||||
_comment: string,
|
||||
): Promise<void> {
|
||||
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
|
||||
name,
|
||||
"address-pool": poolName,
|
||||
responder: "yes",
|
||||
"static-dns": dns,
|
||||
comment,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ export async function ensurePolicyTemplate(
|
||||
export async function ensureIpsecPeer(
|
||||
client: MikrotikClient,
|
||||
name: string,
|
||||
serverCertName: string,
|
||||
_serverCertName: string,
|
||||
profileName: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
@@ -128,8 +129,6 @@ export async function ensureIpsecPeer(
|
||||
address: "0.0.0.0/0",
|
||||
"exchange-mode": "ike2",
|
||||
passive: "yes",
|
||||
certificate: serverCertName,
|
||||
"send-cert": "always",
|
||||
profile: profileName,
|
||||
comment,
|
||||
}))
|
||||
@@ -157,18 +156,21 @@ export async function putUserModeConfig(
|
||||
client: MikrotikClient,
|
||||
name: string,
|
||||
staticIp: string,
|
||||
comment: string,
|
||||
_comment: string,
|
||||
): Promise<void> {
|
||||
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
|
||||
name,
|
||||
address: staticIp,
|
||||
comment,
|
||||
responder: "yes",
|
||||
"address-prefix-length": "32",
|
||||
}))
|
||||
}
|
||||
|
||||
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
|
||||
const id = await findRosId(client, "/ip/ipsec/mode-config", name)
|
||||
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`)
|
||||
const row = (await listByPath(client, "/ip/ipsec/mode-config")).find(row => row.name === name)
|
||||
if (!row?.[".id"] || !isManagedIpsecModeConfig(row)) return
|
||||
if ((await listByPath(client, "/ip/ipsec/identity")).some(i => i["mode-config"] === name)) return
|
||||
await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(row[".id"])}`)
|
||||
}
|
||||
|
||||
export interface IdentityFields {
|
||||
@@ -183,22 +185,21 @@ export interface IdentityFields {
|
||||
/** psk. */
|
||||
secret?: string
|
||||
remoteId?: string
|
||||
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
|
||||
/** Legacy hint: RouterOS identities do not accept place-before; specificity uses match-by/remote-certificate. */
|
||||
placeBefore?: string
|
||||
}
|
||||
|
||||
export function identityRosBody(f: IdentityFields): Record<string, string> {
|
||||
return toRosBody({
|
||||
peer: f.peerName,
|
||||
"auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key",
|
||||
"auth-method": f.authMethod === "certificate" ? "digital-signature" : "pre-shared-key",
|
||||
certificate: f.certificate,
|
||||
"remote-certificate": f.remoteCertificate,
|
||||
"match-by": f.authMethod === "certificate" ? "certificate" : undefined,
|
||||
"match-by": f.authMethod === "certificate" && f.remoteCertificate ? "certificate" : undefined,
|
||||
secret: f.secret,
|
||||
"remote-id": f.remoteId,
|
||||
"remote-id": f.remoteId && !f.remoteId.includes(":") && !["auto", "ignore"].includes(f.remoteId) ? `key-id:${f.remoteId}` : f.remoteId,
|
||||
"mode-config": f.modeConfig,
|
||||
"generate-policy": "port-strict",
|
||||
"place-before": f.placeBefore,
|
||||
comment: f.comment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
@@ -16,7 +16,6 @@ export type IpsecPeerFormState = {
|
||||
address: string
|
||||
exchangeMode: string
|
||||
passive: boolean
|
||||
certificate: string
|
||||
profile: string
|
||||
disabled: boolean
|
||||
}
|
||||
@@ -26,7 +25,6 @@ export const defaultIpsecPeerForm = (): IpsecPeerFormState => ({
|
||||
address: "0.0.0.0/0",
|
||||
exchangeMode: "ike2",
|
||||
passive: true,
|
||||
certificate: "",
|
||||
profile: "",
|
||||
disabled: false,
|
||||
})
|
||||
@@ -39,14 +37,12 @@ function IpsecPeerSheet({
|
||||
onOpenChange,
|
||||
busy,
|
||||
editing,
|
||||
certificates,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
busy?: boolean
|
||||
editing: IpsecPeerDto | null
|
||||
certificates: string[]
|
||||
onSubmit: (form: IpsecPeerFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<IpsecPeerFormState>(defaultIpsecPeerForm)
|
||||
@@ -68,7 +64,6 @@ function IpsecPeerSheet({
|
||||
address: editing.address ?? "",
|
||||
exchangeMode: editing.exchangeMode ?? "ike2",
|
||||
passive: editing.passive ?? false,
|
||||
certificate: editing.certificate ?? "",
|
||||
profile: editing.profile ?? "",
|
||||
disabled: editing.disabled,
|
||||
}))
|
||||
@@ -119,18 +114,7 @@ function IpsecPeerSheet({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Сертификат" hint="Серверный сертификат роутера">
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">Сертификат аутентификации задаётся в identity. Эта форма изменяет только peer.</p>
|
||||
<FormField label="Profile" hint="Пусто — не менять">
|
||||
<Input className="font-mono" aria-label="Profile" value={form.profile} onChange={(e) => set("profile", e.target.value)} />
|
||||
</FormField>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# UX/UI: протокол внешней приёмки 12.5
|
||||
|
||||
Статус на2026-10-09: backend/frontend ux-vxlan76, backend healthy; раздел VXLAN76 принят в описанных границах. В12.5.67 исправленный Peer .conf принят как клиентский шаблон: API6/6, browser4/4, server endpoint и placeholders; настоящий RSC roundtrip через UI/native/handshake/Ping принят в12.5.68, .conf с явнымName/отказы400/409/сохранение формы приняты в70; поздний native rejection/подтверждённая компенсация/retry приняты в72; изолированный audit INSERT permission denial принят в74; остальные неизвестные исходы/DB faults открыты. Мобильная ширина WireGuard Sheets принята в12.5.64. Мобильная GRE форма и native сохранение на обоих CHR приняты в12.5.57. WireGuard handshake/короткий Ping/native API/read-only UI приняты в границах12.5.58; 12.5.59 воспроизвёл оставшиеся peers/IP; 12.5.60 развернул исправление и принял обычное удаление own WG интерфейса с peer/IPv4 адресом на обоих CHR. 12.5.62 принял полный WireGuard UI create/peer/disable/enable/Ping/delete на обоих CHR; устойчивое меню развёрнуто. Устойчивость меню GRE при live refresh на обоих CHR принята в12.5.55. GRE peer metadata и реальный lifecycle/API/UI/двусторонний Ping подтверждены в12.5.53. Browser retry/checkpoint на реальном HTTP/PG принят в границах12.5.43–45; Telegram sender responses подменены, реальная доставка не проверена. PG-1/2/3/4/5 приняты в описанных границах, PG-6 integrated для одного isolated instance, PG-7 real JWT/processcounts и browser restore passed. PG-8 real PG/native и browser unknown→history done приняты с fault injection после настоящего COMMIT. ROS-8/9 real API/SQL/native приняты в границах12.5.23; browser pair/duration change и delete приняты в12.5.24. ROS-2 authentication failure принят в12.5.25. PortalSSO, физический TCP disconnect, мультиэкземпляры и остальные ROS/Q/интеграции остаются открытыми. [Паспорт](ux-ui-test-server.md), [журнал](ux-ui-progress.md). VXLAN75: собственная native пара/Ping/catalog/URL/export приняты, create/delete API отсутствует; selected baseline восстановлен.
|
||||
Статус на2026-10-09: backend/frontend ux-ipsec77, backend healthy; основной блок IPsec77 и VXLAN76 приняты в описанных границах. В12.5.67 исправленный Peer .conf принят как клиентский шаблон: API6/6, browser4/4, server endpoint и placeholders; настоящий RSC roundtrip через UI/native/handshake/Ping принят в12.5.68, .conf с явнымName/отказы400/409/сохранение формы приняты в70; поздний native rejection/подтверждённая компенсация/retry приняты в72; изолированный audit INSERT permission denial принят в74; остальные неизвестные исходы/DB faults открыты. Мобильная ширина WireGuard Sheets принята в12.5.64. Мобильная GRE форма и native сохранение на обоих CHR приняты в12.5.57. WireGuard handshake/короткий Ping/native API/read-only UI приняты в границах12.5.58; 12.5.59 воспроизвёл оставшиеся peers/IP; 12.5.60 развернул исправление и принял обычное удаление own WG интерфейса с peer/IPv4 адресом на обоих CHR. 12.5.62 принял полный WireGuard UI create/peer/disable/enable/Ping/delete на обоих CHR; устойчивое меню развёрнуто. Устойчивость меню GRE при live refresh на обоих CHR принята в12.5.55. GRE peer metadata и реальный lifecycle/API/UI/двусторонний Ping подтверждены в12.5.53. Browser retry/checkpoint на реальном HTTP/PG принят в границах12.5.43–45; Telegram sender responses подменены, реальная доставка не проверена. PG-1/2/3/4/5 приняты в описанных границах, PG-6 integrated для одного isolated instance, PG-7 real JWT/processcounts и browser restore passed. PG-8 real PG/native и browser unknown→history done приняты с fault injection после настоящего COMMIT. ROS-8/9 real API/SQL/native приняты в границах12.5.23; browser pair/duration change и delete приняты в12.5.24. ROS-2 authentication failure принят в12.5.25. PortalSSO, физический TCP disconnect, мультиэкземпляры и остальные ROS/Q/интеграции остаются открытыми. [Паспорт](ux-ui-test-server.md), [журнал](ux-ui-progress.md). VXLAN75: собственная native пара/Ping/catalog/URL/export приняты, create/delete API отсутствует; selected baseline восстановлен.
|
||||
|
||||
## Паспорт стенда
|
||||
|
||||
@@ -681,3 +681,9 @@ flowchart LR
|
||||
```
|
||||
|
||||
Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе.
|
||||
|
||||
## 12.5.77 — основной блок IPsec (2026-10-09)
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
||||
+8
-2
@@ -1,12 +1,12 @@
|
||||
# План реализации UX/UI по пятичасовым окнам
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
## Актуальная точка продолжения — 2026-10-09
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
|
||||
|
||||
@@ -368,3 +368,9 @@ flowchart LR
|
||||
```
|
||||
|
||||
Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе.
|
||||
|
||||
## 12.5.77 — основной блок IPsec (2026-10-09)
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
||||
+40
-2
@@ -1,6 +1,6 @@
|
||||
# UX/UI: журнал реализации
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
||||
|
||||
@@ -36,7 +36,45 @@
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–76 и ограничения ниже. Runtime backend/frontend ux-vxlan76. |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–77 и ограничения ниже. Runtime backend/frontend ux-ipsec77. |
|
||||
|
||||
## 12.5.77 — IPsec: основной раздел, RouterOS7 и реальная профильная приёмка (2026-10-09)
|
||||
|
||||
Укрупнённый блок включает backend/API/UI, тесты, production Docker build/deploy, собственные реальные объекты на обоих CHR и очистку. Один локальный коммит после блока, без push. Установленные ReUI/Frame/base-nova компоненты сохранены.
|
||||
|
||||
**Достоверность и операции.** Чтение восьми коллекций IPsec больше не скрывает отказ RouterOS или некорректный контейнер/строки/ID пустым успешным списком. Агрегатор сохраняет успешные устройства и сообщает ошибку конкретного устройства; observed history не запускается для неудачного среза. Выключенный сервер исключён из чтения и операций, ID разбирается целиком. Init/User/Peer формы сохраняют поля при отказах по существующим шаблонам. Статический IPv4 проверяется до изменений; отсутствующий явный peer и дубликат клиента отклоняются. Зарезервированные имена mode-config учитываются в истории и очистке, поскольку RouterOS7.24.5 отклонил comment.
|
||||
|
||||
**Совместимость RouterOS7.** Мастер создаёт сертификатную identity с digital-signature, certificate находится в identity; неподдерживаемые certificate/send-cert убраны из peer и restore body. Исправлены responder=yes для mode-config, доверие CA, неподдерживаемые profile/mode-config comment и identity place-before. Из генерации убраны DH1024/1536 и SHA1. Сертификатная роль учитывает identity references. Plain PSK remote ID при создании превращается в key-id. Отображаемое имя сертификатного клиента хранится в identity, подписанный CN/ключ/имя сертификата сохраняются.
|
||||
|
||||
**Выбор объекта и удаление.** Клиенты используют явные rosId namespace identity:/cert:; совпадение ID разных RouterOS меню больше не выбирает чужой объект. Неоднозначный legacy ID даёт409 с предложением обновить список; CA не является клиентом. Удаление сервера заранее читает весь объём, сохраняет стандартный/чужой policy template, не скрывает ошибки и удаляет CA после leaf сертификатов. Чужие identity/policy и сертификат, подписанный управляемым CA, блокируют удаление до первой записи. Общий/чужой mode-config сохраняется при удалении клиента. Это проверки перечисленных ссылок, не гарантия учёта всех внешних потребителей PKI.
|
||||
|
||||
**Контекст и интерфейс.** URL сохраняет server и tab при reload; неизвестное устройство остаётся отдельным срезом с ошибкой и заблокированным созданием клиента. Сохранена цель мастера при переключении контекста. Форма peer больше не предлагает сертификат, относящийся к identity. Реальный browser выявил возврат вкладки после закрытия Sheet; локальные query-state обновления переведены на History API без отдельного RSC перехода. [Next.js API](https://nextjs.org/docs/app/guides/single-page-applications). Тестовые PNG сохраняются в каталог конкретного запуска после ошибки записи общего файла Windows.
|
||||
|
||||
**Проверки.** npm backend test:ipsec:67 новых проверок + config tests, entity-snapshots passed; backend/scoped frontend TypeScript и ESLint passed. Итоговый профильный набор30/30 (включает связанные GRE cases), context-race6/6. Реальный browser4/4: оба CHR ×1280/320px, готовность, peer/client формы, URL/tab/reload и отсутствие page overflow; без HTTP mocks. [Browser protocol](ux-ui/acceptance/browser-ipsec-section-2026-10-09.json). Полная регрессия проекта не повторялась.
|
||||
|
||||
**Реальная схема.** На194.226.121.150 и104.165.244.7 выполнены HTTP init без NAT, создание/disable/enable/duplicate PSK клиента,400 на неверный IP,404 на отсутствующий peer, выпуск сертификатов, проверка .p12 через OpenSSL и сохранение CN при переименовании. Для сетевой проверки native REST создал собственные profile/proposal/peer/identity/policy/GRE и198.18.255.249/30↔.250/30; IPsec encrypt/require защищал GRE между публичными /32, без изменения firewall/default route. PSK, затем digital-signature с реальным экспортированным клиентским ключом: Ping3/3 в обе стороны, ph2-state established и ненулевые SA bytes. В импортированной цепочке CA/leaf получили одинаковое имя от RouterOS; тест назначает им уникальные имена по ID. Сертификатная роль задана явно: CHR1 responder, CHR2 initiator. Это native site-to-site проверка криптографии/экспорта; выдача road-warrior lease и импорт через приложение не объявляются проверенными.
|
||||
|
||||
**Ошибки чтения и выключенные устройства.** Две собственные временные DB записи home-router/exit-node указывали на HTTP probe. Выключенные GET сообщали disabled, операции404, native calls0. Включение только fake endpoint вернуло настоящий upstream401: не было ложной успешной пустой сводки, два исправных CHR сохранились в частичном результате;16 probe calls на два чтения. Все fake записи удалены. [Native protocol](ux-ui/acceptance/ipsec-section-2026-10-09.json).
|
||||
|
||||
**Развёртывание и состояние.** Образы backend/frontend ux-ipsec77/version1.0.0-test.ipsec77 собраны и развёрнуты;3 services running, backend/PG healthy, frontend проверен HTTP/browser. Source: архив базового HEAD e2d81f9 +14 явных working-file overrides, SHA256 e0a034a855eb2d07696a1849c9baed738890e7c84108d5a3d6dbe652188bee9e; [manifest](ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json). Это не архив одного уже закоммиченного revision. Приватный pre-deploy PG backup2510309 bytes/0600, pg_restore --list passed; .env/прежние VXLAN образы сохранены на сервере. DB после: servers2/speedHistory9/retention14/migrations9. Пароли не менялись; оба CHR/SSH доступны, единичный401 CHR2 при контрольном чтении не повторился. [Deployment](ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json), [smoke8 страниц](ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json).
|
||||
|
||||
Очистка последнего цикла сравнила IPsec/PKI/GRE/IP/routes/IPv4 firewall/file metadata на обоих CHR: все проверки true. При восстановлении раннего цикла широкий отбор отклонила автоматическая проверка: выполнены инвентаризация и preflight17 точных ID/атрибутов собственного запуска, затем их разрешённая очистка. Удаление каталога flash наCHR2 отклонено отдельно: системное имя/пустота не доказывают ownership; каталог сохранён. Приватные .p12/.pass/.pem теста удалены, ключи не включены в отчёт/Git.
|
||||
|
||||
| Референс | Схема и реальные экраны |
|
||||
| --- | --- |
|
||||
| [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Устройство/URL → достоверный срез → клиент; [ПК](ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png), [телефон](ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png) |
|
||||
| [Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp), [Tabs API/preview](https://reui.io/components/tabs?ref=mcp) | Peer/клиент → сохранение ввода → подтверждение API → возврат в тот же срез |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Устройство и вкладка в URL] --> B{Достоверный срез?}
|
||||
B -->|Нет| C[Ошибка и последние данные]
|
||||
B -->|Да| D[Peer или клиент с явным ID]
|
||||
D --> E[Форма и подтверждение API]
|
||||
E --> F[Native проверка и возврат]
|
||||
```
|
||||
|
||||
Остаточные границы: online/active-peer корреляция, настоящая выдача mode-config, NAT/интернет, IPv6, Windows/macOS/iOS/Android подключения, late failure receipts/DB/transport, history restore, многопроцессность, IPsec-specific live JWT/RBAC и throughput не приняты этим блоком. Основной раздел внедрён и принят в перечисленных сценариях; этап12.5 и эти профильные ограничения остаются открытыми.
|
||||
|
||||
## 12.5.76 — VXLAN: достоверность, экспорт и полная профильная приёмка (2026-10-09)
|
||||
|
||||
|
||||
+39
-1
@@ -1,6 +1,6 @@
|
||||
# Этап 12: итоговая приёмка UX/UI
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.76 завершил укрупнённый раздел VXLAN: достоверный снимок/MTU, запрет опроса выключенных устройств, устойчивое меню, Docker deploy обеих частей и реальная приёмка двух CHR. Browser4/4, повторное применение экспортированного fragment с MTU1400 и Ping3/3 в обе стороны, probe0 и cleanup passed. Runtime backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; происхождение сборки — base HEAD6b068ac плюс явный manifest13 файлов. Следующий12.5.77 — IPsec целиком: карта API/операций, собственная тестовая пара, профильные проверки и исправления, deployment/приёмка/cleanup, один локальный коммит. Этап12.5 открыт.
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
|
||||
|
||||
@@ -1846,3 +1846,41 @@ flowchart LR
|
||||
```
|
||||
|
||||
Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе.
|
||||
|
||||
## 12.5.77 — IPsec: основной раздел, RouterOS7 и реальная профильная приёмка (2026-10-09)
|
||||
|
||||
Укрупнённый блок включает backend/API/UI, тесты, production Docker build/deploy, собственные реальные объекты на обоих CHR и очистку. Один локальный коммит после блока, без push. Установленные ReUI/Frame/base-nova компоненты сохранены.
|
||||
|
||||
**Достоверность и операции.** Чтение восьми коллекций IPsec больше не скрывает отказ RouterOS или некорректный контейнер/строки/ID пустым успешным списком. Агрегатор сохраняет успешные устройства и сообщает ошибку конкретного устройства; observed history не запускается для неудачного среза. Выключенный сервер исключён из чтения и операций, ID разбирается целиком. Init/User/Peer формы сохраняют поля при отказах по существующим шаблонам. Статический IPv4 проверяется до изменений; отсутствующий явный peer и дубликат клиента отклоняются. Зарезервированные имена mode-config учитываются в истории и очистке, поскольку RouterOS7.24.5 отклонил comment.
|
||||
|
||||
**Совместимость RouterOS7.** Мастер создаёт сертификатную identity с digital-signature, certificate находится в identity; неподдерживаемые certificate/send-cert убраны из peer и restore body. Исправлены responder=yes для mode-config, доверие CA, неподдерживаемые profile/mode-config comment и identity place-before. Из генерации убраны DH1024/1536 и SHA1. Сертификатная роль учитывает identity references. Plain PSK remote ID при создании превращается в key-id. Отображаемое имя сертификатного клиента хранится в identity, подписанный CN/ключ/имя сертификата сохраняются.
|
||||
|
||||
**Выбор объекта и удаление.** Клиенты используют явные rosId namespace identity:/cert:; совпадение ID разных RouterOS меню больше не выбирает чужой объект. Неоднозначный legacy ID даёт409 с предложением обновить список; CA не является клиентом. Удаление сервера заранее читает весь объём, сохраняет стандартный/чужой policy template, не скрывает ошибки и удаляет CA после leaf сертификатов. Чужие identity/policy и сертификат, подписанный управляемым CA, блокируют удаление до первой записи. Общий/чужой mode-config сохраняется при удалении клиента. Это проверки перечисленных ссылок, не гарантия учёта всех внешних потребителей PKI.
|
||||
|
||||
**Контекст и интерфейс.** URL сохраняет server и tab при reload; неизвестное устройство остаётся отдельным срезом с ошибкой и заблокированным созданием клиента. Сохранена цель мастера при переключении контекста. Форма peer больше не предлагает сертификат, относящийся к identity. Реальный browser выявил возврат вкладки после закрытия Sheet; локальные query-state обновления переведены на History API без отдельного RSC перехода. [Next.js API](https://nextjs.org/docs/app/guides/single-page-applications). Тестовые PNG сохраняются в каталог конкретного запуска после ошибки записи общего файла Windows.
|
||||
|
||||
**Проверки.** npm backend test:ipsec:67 новых проверок + config tests, entity-snapshots passed; backend/scoped frontend TypeScript и ESLint passed. Итоговый профильный набор30/30 (включает связанные GRE cases), context-race6/6. Реальный browser4/4: оба CHR ×1280/320px, готовность, peer/client формы, URL/tab/reload и отсутствие page overflow; без HTTP mocks. [Browser protocol](ux-ui/acceptance/browser-ipsec-section-2026-10-09.json). Полная регрессия проекта не повторялась.
|
||||
|
||||
**Реальная схема.** На194.226.121.150 и104.165.244.7 выполнены HTTP init без NAT, создание/disable/enable/duplicate PSK клиента,400 на неверный IP,404 на отсутствующий peer, выпуск сертификатов, проверка .p12 через OpenSSL и сохранение CN при переименовании. Для сетевой проверки native REST создал собственные profile/proposal/peer/identity/policy/GRE и198.18.255.249/30↔.250/30; IPsec encrypt/require защищал GRE между публичными /32, без изменения firewall/default route. PSK, затем digital-signature с реальным экспортированным клиентским ключом: Ping3/3 в обе стороны, ph2-state established и ненулевые SA bytes. В импортированной цепочке CA/leaf получили одинаковое имя от RouterOS; тест назначает им уникальные имена по ID. Сертификатная роль задана явно: CHR1 responder, CHR2 initiator. Это native site-to-site проверка криптографии/экспорта; выдача road-warrior lease и импорт через приложение не объявляются проверенными.
|
||||
|
||||
**Ошибки чтения и выключенные устройства.** Две собственные временные DB записи home-router/exit-node указывали на HTTP probe. Выключенные GET сообщали disabled, операции404, native calls0. Включение только fake endpoint вернуло настоящий upstream401: не было ложной успешной пустой сводки, два исправных CHR сохранились в частичном результате;16 probe calls на два чтения. Все fake записи удалены. [Native protocol](ux-ui/acceptance/ipsec-section-2026-10-09.json).
|
||||
|
||||
**Развёртывание и состояние.** Образы backend/frontend ux-ipsec77/version1.0.0-test.ipsec77 собраны и развёрнуты;3 services running, backend/PG healthy, frontend проверен HTTP/browser. Source: архив базового HEAD e2d81f9 +14 явных working-file overrides, SHA256 e0a034a855eb2d07696a1849c9baed738890e7c84108d5a3d6dbe652188bee9e; [manifest](ux-ui/acceptance/ipsec-section-source-manifest-2026-10-09.json). Это не архив одного уже закоммиченного revision. Приватный pre-deploy PG backup2510309 bytes/0600, pg_restore --list passed; .env/прежние VXLAN образы сохранены на сервере. DB после: servers2/speedHistory9/retention14/migrations9. Пароли не менялись; оба CHR/SSH доступны, единичный401 CHR2 при контрольном чтении не повторился. [Deployment](ux-ui/acceptance/deploy-ipsec-section-2026-10-09.json), [smoke8 страниц](ux-ui/acceptance/deploy-browser-ipsec-section-2026-10-09.json).
|
||||
|
||||
Очистка последнего цикла сравнила IPsec/PKI/GRE/IP/routes/IPv4 firewall/file metadata на обоих CHR: все проверки true. При восстановлении раннего цикла широкий отбор отклонила автоматическая проверка: выполнены инвентаризация и preflight17 точных ID/атрибутов собственного запуска, затем их разрешённая очистка. Удаление каталога flash наCHR2 отклонено отдельно: системное имя/пустота не доказывают ownership; каталог сохранён. Приватные .p12/.pass/.pem теста удалены, ключи не включены в отчёт/Git.
|
||||
|
||||
| Референс | Схема и реальные экраны |
|
||||
| --- | --- |
|
||||
| [Data Grid Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Устройство/URL → достоверный срез → клиент; [ПК](ux-ui/screens/ipsec-real-clients-server1-1280-2026-10-09.png), [телефон](ux-ui/screens/ipsec-real-clients-server2-320-2026-10-09.png) |
|
||||
| [Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp), [Tabs API/preview](https://reui.io/components/tabs?ref=mcp) | Peer/клиент → сохранение ввода → подтверждение API → возврат в тот же срез |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Устройство и вкладка в URL] --> B{Достоверный срез?}
|
||||
B -->|Нет| C[Ошибка и последние данные]
|
||||
B -->|Да| D[Peer или клиент с явным ID]
|
||||
D --> E[Форма и подтверждение API]
|
||||
E --> F[Native проверка и возврат]
|
||||
```
|
||||
|
||||
Остаточные границы: online/active-peer корреляция, настоящая выдача mode-config, NAT/интернет, IPv6, Windows/macOS/iOS/Android подключения, late failure receipts/DB/transport, history restore, многопроцессность, IPsec-specific live JWT/RBAC и throughput не приняты этим блоком. Основной раздел внедрён и принят в перечисленных сценариях; этап12.5 и эти профильные ограничения остаются открытыми.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Тестовый сервер UX/UI — 2026-10-09
|
||||
|
||||
Сервер161.104.46.222 предоставлен для временного тестирования. Backend/frontend ux-vxlan76, version1.0.0-test.vxlan76; backend/PostgreSQL healthy. SSH и оба CHR доступны с прежними доступами. Раздел VXLAN76 принят в описанных ниже границах; собственные объекты и временные выключенные DB записи удалены, selected baseline восстановлен.
|
||||
Сервер161.104.46.222 предоставлен для временного тестирования. Backend/frontend ux-ipsec77/version1.0.0-test.ipsec77; backend/PG healthy, frontend HTTP/browser passed. Основной блок IPsec77 принят в границах журнала, own fixtures/fake DB rows удалены; каталог flash CHR2 сохранён из-за неподтверждённого ownership. SSH/оба CHR доступны, пароли прежние.
|
||||
|
||||
| Параметр | Проверенное состояние |
|
||||
| --- | --- |
|
||||
@@ -14,7 +14,7 @@
|
||||
| PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
|
||||
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
|
||||
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска |
|
||||
| Образы приложения | mmapp-test/backend:ux-vxlan76 и mmapp-test/frontend:ux-vxlan76; запущены |
|
||||
| Образы приложения | mmapp-test/backend:ux-ipsec77 и mmapp-test/frontend:ux-ipsec77; запущены |
|
||||
|
||||
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
|
||||
|
||||
@@ -689,3 +689,9 @@ flowchart LR
|
||||
```
|
||||
|
||||
Границы: app VXLAN API read-only, create/delete выполнены native REST только на собственных объектах. IPv6/bridge/FDB/throughput/RBAC/concurrency и произвольный терминальный .rsc import не приняты; динамический ARP/FDB baseline не сравнивался. Первый запуск Edge в sandbox завершился до страницы; повторный разрешённый запуск прошёл. Этап12.5 целиком не закрыт. Следующий раздел12.5.77 — IPsec; остаточные VXLAN/WireGuard ограничения остаются в протоколе.
|
||||
|
||||
## 12.5.77 — основной блок IPsec (2026-10-09)
|
||||
|
||||
Актуальное продолжение 2026-10-09: 12.5.77 завершил укрупнённый основной блок IPsec: достоверное чтение, запрет опроса выключенных устройств, совместимый RouterOS7 мастер, раздельные ID клиентов, безопаснее удаление, URL устройства/вкладки, Docker обеих частей и реальная приёмка. Local30/30 + context6/6 + backend67, live browser4/4, PSK и сертификатный защищённый GRE/Ping3/3 на двух CHR; cleanup/DB controls passed. Runtime backend/frontend ux-ipsec77, version1.0.0-test.ipsec77. Следующий12.5.78 — остаточная профильная приёмка IPsec: сопоставление active-peer/online, выдача mode-config, поздние ошибки операций и история восстановления; затем маршрутизация. Этап12.5 открыт.
|
||||
|
||||
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
|
||||
@@ -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 |
@@ -27,7 +27,7 @@ for (const device of ["desktop", "mobile"] as const) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
mkdirSync("docs/ux-ui/screens", { recursive: true })
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/ipsec-${device}.png`, animations: "disabled" })
|
||||
await page.screenshot({ path: test.info().outputPath(`ipsec-${device}.png`), animations: "disabled" })
|
||||
if (device === "mobile") {
|
||||
await page.getByRole("button", { name: "Серверы", exact: true }).click()
|
||||
await page.getByRole("dialog").getByRole("option").filter({ hasText: "Резервный VPN роутер" }).click()
|
||||
@@ -40,7 +40,7 @@ for (const device of ["desktop", "mobile"] as const) {
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Имя клиента" }).fill("Проверенный клиент")
|
||||
await expect.poll(() => dialog.evaluate(el => el.getBoundingClientRect().left >= 0 && el.getBoundingClientRect().right <= innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/ipsec-client-${device}.png`, animations: "disabled" })
|
||||
await page.screenshot({ path: test.info().outputPath(`ipsec-client-${device}.png`), animations: "disabled" })
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
@@ -52,6 +52,6 @@ for (const device of ["desktop", "mobile"] as const) {
|
||||
await page.clock.fastForward(6000)
|
||||
await expect(page.getByText("Клиент обновлён", { exact: true })).toHaveCount(0)
|
||||
if (device === "mobile") await page.getByRole("button", { name: "Переинициализировать", exact: true }).scrollIntoViewIfNeeded()
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/ipsec-servers-${device}.png`, animations: "disabled" })
|
||||
await page.screenshot({ path: test.info().outputPath(`ipsec-servers-${device}.png`), animations: "disabled" })
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user