fix(ipsec): обеспечить NAT и изоляцию политик для передачи данных

This commit is contained in:
Denozordec committed 2026-10-09 17:10:33 +07:00
1 parent 43b00e0740
commit 89de7e134b
17 files changed
+571 -25

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@
"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 && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts",
"test:ipsec": "tsx src/services/ipsec-config.test.ts && tsx src/services/ipsec-state.test.ts && tsx src/routes/ipsec-history.test.ts && tsx src/services/ipsec-restore.test.ts && tsx src/services/ipsec-operation.test.ts && tsx src/routes/ipsec-mutation.test.ts && tsx src/routes/ipsec-nat.test.ts && tsx src/routes/ipsec-group.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: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",
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createIpsecRoutes } from "./ipsec.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { fetchIpsecState } from "../services/ipsec-live.js"
import { IPSEC_POLICY_GROUP } from "../services/ipsec-config.js"
import { ipsecPolicyGroupExists, putIdentity, ensurePolicyTemplate } from "../services/ipsec-ros.js"
import { removeManagedIpsecServer } from "../services/ipsec-cleanup.js"
const ownComment="MikrotikManager:ipsec test"
const groups=[{".id":"*DEFAULT",name:"default"},{".id":"*GROUP",name:IPSEC_POLICY_GROUP}]
for(const foreign of [false,true]){const removed:string[]=[];const client={get:async(p:string)=>p==="/ip/ipsec/policy/group"?groups:p==="/ip/ipsec/policy"?[{".id":"*POLICY",group:IPSEC_POLICY_GROUP,comment:ownComment}]:p==="/ip/ipsec/identity"&&foreign?[{".id":"*F",comment:"foreign","policy-template-group":IPSEC_POLICY_GROUP}]:[],delete:async(p:string)=>{removed.push(p)}} as unknown as MikrotikClient
if(foreign){await assert.rejects(ipsecPolicyGroupExists(client),/чужой/);await assert.rejects(removeManagedIpsecServer(client,false),/чужой/);assert.deepEqual(removed,[])}else{assert.equal(await ipsecPolicyGroupExists(client),true);await removeManagedIpsecServer(client,false);assert.deepEqual(removed,["/ip/ipsec/policy/*POLICY","/ip/ipsec/policy/group/*GROUP"])} }
let identityBody:Record<string,string>|undefined,templateBody:Record<string,string>|undefined
await putIdentity({get:async()=>[{".id":"*SHARED",peer:"ipsec-vpn",certificate:"server","auth-method":"digital-signature",comment:ownComment,"policy-template-group":IPSEC_POLICY_GROUP}],put:async(_p:string,b:Record<string,string>)=>{identityBody=b}} as unknown as MikrotikClient,{peerName:"ipsec-vpn",modeConfig:"mc",comment:ownComment,authMethod:"pre-shared-key",remoteId:"alice"})
assert.equal(identityBody?.["policy-template-group"],IPSEC_POLICY_GROUP)
await ensurePolicyTemplate({get:async()=>[],put:async(_p:string,b:Record<string,string>)=>{templateBody=b}} as unknown as MikrotikClient,"198.18.82.0/24","ipsec-vpn",ownComment)
assert.equal(templateBody?.group,IPSEC_POLICY_GROUP)
const original=MikrotikClient.fromServer;let writes=0;MikrotikClient.fromServer=()=>({get:async(p:string)=>p==="/ip/ipsec/policy/group"?groups:[],put:async()=>{writes++},patch:async()=>{writes++},delete:async()=>{writes++},post:async()=>{writes++}}) as unknown as MikrotikClient
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
try{await app.register(createIpsecRoutes({findServer:async()=>({id:1,enabled:true} as Parameters<typeof fetchIpsecState>[0]),recordRevision:async()=>{throw Error("must not record")}}),{prefix:"/api"});const r=await app.inject({method:"POST",url:"/api/ipsec/server/init",payload:{serverId:1,serverEndpoint:"192.0.2.1",poolCidr:"198.18.82.0/24",createNatRule:false}});assert.equal(r.statusCode,409);assert.equal(writes,0)}finally{await app.close();MikrotikClient.fromServer=original}
console.log("IPsec groups: template/client binding, foreign reuse/reference no-write guards, default retained and owned group cleanup")
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createIpsecRoutes } from "./ipsec.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { fetchIpsecState } from "../services/ipsec-live.js"
import { ensureNatRule, selectIpsecNatInterface } from "../services/ipsec-ros.js"
const interfaces=[{".id":"*E",name:"ether1"},{".id":"*E2",name:"ether2"},{".id":"*PPP",name:"pppoe-out1"}]
const route={".id":"*R","dst-address":"0.0.0.0/0",active:"true","routing-table":"main","immediate-gw":"192.0.2.1%ether1"}
assert.equal(selectIpsecNatInterface([route,{...route,".id":"*R2"}],interfaces),"ether1")
assert.equal(selectIpsecNatInterface([{...route,"immediate-gw":"pppoe-out1"}],interfaces),"pppoe-out1")
for(const routes of [[],[{...route,active:"false"}],[{...route,"routing-table":"other"}],[route,{...route,"immediate-gw":"192.0.2.2%ether2"}]])assert.throws(()=>selectIpsecNatInterface(routes,interfaces),/однозначно/)
let writes=0
const original=MikrotikClient.fromServer
const server={id:1,enabled:true,host:"192.0.2.1"} as Parameters<typeof fetchIpsecState>[0]
MikrotikClient.fromServer=()=>({get:async(p:string)=>p==="/ip/route"?[]:interfaces,put:async()=>{writes++},patch:async()=>{writes++},post:async()=>{writes++},delete:async()=>{writes++}}) as unknown as MikrotikClient
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
try {await app.register(createIpsecRoutes({findServer:async()=>server,recordRevision:async()=>{throw Error("must not record")}}),{prefix:"/api"});const r=await app.inject({method:"POST",url:"/api/ipsec/server/init",payload:{serverId:1,serverEndpoint:"192.0.2.1",poolCidr:"198.18.82.0/24",createNatRule:true}});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.match(r.json().error,/Изменений не было/)}finally{await app.close();MikrotikClient.fromServer=original}
for(const existing of [false,true]){let body:Record<string,string>|undefined;let method="";await ensureNatRule({get:async()=>existing?[{".id":"*N",chain:"srcnat",comment:"owned","out-interface-list":"WAN"}]:[],put:async(_p:string,b:Record<string,string>)=>{method="put";body=b},patch:async(_p:string,b:Record<string,string>)=>{method="patch";body=b}} as unknown as MikrotikClient,"198.18.82.0/24","owned","ether1");assert.equal(method,existing?"patch":"put");assert.equal(body?.["out-interface"],"ether1");assert.equal(body?.["ipsec-policy"],"out,none");assert.equal(body?.["out-interface-list"],undefined)}
console.log("IPsec NAT: verified egress, PPP/duplicates, missing/ambiguous/VRF no-write preflight and scoped create/update")
+21 -2
View File
@@ -15,6 +15,7 @@ import {
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
IPSEC_COMMON_NAME,
IPSEC_POLICY_GROUP,
IPSEC_SERVER_CERT,
buildClientInstructions,
buildSswanConfig,
@@ -39,6 +40,11 @@ import {
ensureIpsecProfile,
ensureIpsecProposal,
ensureNatRule,
resolveIpsecNatInterface,
IpsecNatInterfaceConflict,
IpsecPolicyGroupConflict,
ipsecPolicyGroupExists,
listByPath,
ensurePolicyTemplate,
ensureSharedModeConfig,
putUserModeConfig,
@@ -96,6 +102,8 @@ function rosIdParam(v: string): string {
}
function errReply(reply: FastifyReply, e: unknown, progress?: IpsecMutationProgress) {
if (e instanceof IpsecNatInterfaceConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
if (e instanceof IpsecPolicyGroupConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
if (e instanceof IpsecRestoreConflict) return reply.status(409).send({ error: `${e.message}. Изменений не было` })
if (e instanceof IpsecHistoryError) return reply.status(502).send({ error: e.message, state: "applied", historyRecorded: false })
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
@@ -214,6 +222,8 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
const progress = trackIpsecMutation(MikrotikClient.fromServer(server))
const client = progress.client
try {
const natInterface = body.createNatRule ? await resolveIpsecNatInterface(client) : undefined
const groupExists = await ipsecPolicyGroupExists(client)
const caCert = await ensureCaCertificate(client, body.caDaysValid)
const serverCert = await ensureServerCertificate(client, {
serverEndpoint: body.serverEndpoint,
@@ -225,11 +235,15 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
await ensureIpsecProposal(client, IPSEC_COMMON_NAME, comment)
await ensureIpsecPool(client, IPSEC_COMMON_NAME, ranges, comment)
await ensureSharedModeConfig(client, IPSEC_COMMON_NAME, IPSEC_COMMON_NAME, body.dns, comment)
if (!groupExists) await client.put("/ip/ipsec/policy/group", { name: IPSEC_POLICY_GROUP })
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") })
await ensureSharedIke2Identity(client, { peerName: IPSEC_COMMON_NAME, serverCertName: serverCert, modeConfigName: IPSEC_COMMON_NAME, comment: ipsecManagedComment("IKEv2 listener"), policyTemplateGroup: IPSEC_POLICY_GROUP })
for (const identity of await listByPath(client, "/ip/ipsec/identity")) {
if (identity.peer === IPSEC_COMMON_NAME && isIpsecManagedComment(identity.comment) && (!identity["policy-template-group"] || identity["policy-template-group"] === "default")) await patchIdentity(client, identity[".id"]!, { "policy-template-group": IPSEC_POLICY_GROUP })
}
if (body.createNatRule) {
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"))
await ensureNatRule(client, body.poolCidr, ipsecManagedComment("интернет клиентам VPN"), natInterface!)
}
progress.complete()
await recordIpsec(server, "apply")
@@ -775,6 +789,11 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs
const profiles = await state.client.get<unknown>("/ip/ipsec/profile")
if (!Array.isArray(profiles) || profiles.some(p => !p || typeof p.name !== "string")) throw new Error("Некорректный список IPsec profiles; откат не начат")
if (desired.peers.some(p => p.profile && !profiles.some(live => live.name === p.profile))) return reply.status(409).send({ error: "На устройстве отсутствует profile ревизии. Восстановите profile перед откатом; изменений не было" })
if (desired.identities.some(i => i.policyTemplateGroup)) {
const groups = await state.client.get<unknown>("/ip/ipsec/policy/group")
if (!Array.isArray(groups) || groups.some(g => !g || typeof g.name !== "string")) throw new Error("Некорректный список групп политик; откат не начат")
if (desired.identities.some(i => i.policyTemplateGroup && !groups.some(g => g.name === i.policyTemplateGroup))) return reply.status(409).send({ error: "Группа политик ревизии отсутствует. Восстановите её перед откатом; изменений не было" })
}
const ops = planIpsecRestore(desired, {
peers: state.peers,
identities: state.identities,
@@ -170,7 +170,7 @@ import { fingerprintPayload, revisionItemCount } from "./config-revisions.js"
identities: [{ peerName: "ipsec-vpn", authMethod: "rsa-key", certificate: "ipsec-server", remoteCertificate: "ipsec-user-alice", matchBy: "certificate", secret: "", remoteId: "", modeConfig: "ipsec-vpn", generatePolicy: "port-strict", comment: "MikrotikManager:ipsec user=alice", disabled: false }],
modeConfigs: [{ name: "ipsec-vpn", addressPool: "ipsec-vpn", address: "", staticDns: "10.77.0.1", comment: "MikrotikManager:ipsec" }],
pools: [{ name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", comment: "MikrotikManager:ipsec" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
nat: [{ chain: "srcnat", action: "masquerade", srcAddress: "10.77.0.0/24", outInterface: "ether1", ipsecPolicy: "out,none", comment: "MikrotikManager:ipsec интернет клиентам VPN" }],
})
// round-trip через payload ревизии
const restored = parseIpsecSnapshot(JSON.parse(JSON.stringify(desired)))
+17 -2
View File
@@ -190,6 +190,7 @@ export interface IpsecSnapshotIdentity {
generatePolicy: string
comment: string
disabled: boolean
policyTemplateGroup?: string
}
export interface IpsecSnapshotModeConfig {
@@ -218,6 +219,9 @@ export interface IpsecSnapshotNat {
action: string
srcAddress: string
comment: string
outInterface?: string
outInterfaceList?: string
ipsecPolicy?: string
}
export interface IpsecSnapshot {
@@ -626,6 +630,7 @@ function canonicalIpsecIdentityRaw(i: Partial<IpsecSnapshotIdentity>): IpsecSnap
remoteId: str(i.remoteId),
modeConfig: str(i.modeConfig),
generatePolicy: str(i.generatePolicy),
...(str(i.policyTemplateGroup) ? { policyTemplateGroup: str(i.policyTemplateGroup) } : {}),
comment: str(i.comment),
disabled: Boolean(i.disabled),
}
@@ -668,7 +673,7 @@ export function canonicalIpsecSnapshot(input: {
.filter((p) => p.dstAddress)
.sort((a, b) => a.dstAddress.localeCompare(b.dstAddress)),
nat: (input.nat ?? [])
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment) }))
.map((n) => ({ chain: str(n.chain), action: str(n.action), srcAddress: str(n.srcAddress), comment: str(n.comment), ...(str(n.outInterface) ? { outInterface: str(n.outInterface) } : {}), ...(str(n.outInterfaceList) ? { outInterfaceList: str(n.outInterfaceList) } : {}), ...(str(n.ipsecPolicy) ? { ipsecPolicy: str(n.ipsecPolicy) } : {}) }))
.filter((n) => n.comment)
.sort((a, b) => a.comment.localeCompare(b.comment)),
}
@@ -712,6 +717,7 @@ function ipsecIdentityBody(i: IpsecSnapshotIdentity): Record<string, string> {
"remote-id": i.remoteId,
"mode-config": i.modeConfig,
"generate-policy": i.generatePolicy,
"policy-template-group": i.policyTemplateGroup,
comment: i.comment,
disabled: rosYesNo(i.disabled),
})
@@ -733,12 +739,16 @@ function ipsecPoolBody(p: IpsecSnapshotPool): Record<string, string> {
}
function ipsecNatBody(n: IpsecSnapshotNat): Record<string, string> {
return compactBody({
const body = compactBody({
chain: n.chain,
action: n.action,
"src-address": n.srcAddress,
comment: n.comment,
"ipsec-policy": n.ipsecPolicy,
"out-interface": n.outInterface,
"out-interface-list": n.outInterfaceList,
})
return body
}
/** Restore только managed-объектов IKEv2 (peer/identity/mode-config/pool/nat; секреты (hidden) не перезаписываем;
@@ -757,11 +767,16 @@ export function planIpsecRestore(
const desired = canonicalIpsecSnapshot(desiredInput)
const ops: RosWriteOp[] = []
const removeDependents: RosWriteOp[] = []
if (desired.nat.some(n => n.action === "masquerade" && !n.outInterface && !n.outInterfaceList)) throw new IpsecRestoreConflict("Ревизия NAT не содержит исходящий интерфейс. Настройте NAT вручную или используйте новую ревизию")
const managedPeers = current.peers.filter((p) => isIpsecManagedComment(p.comment))
const managedIdentities = current.identities.filter((i) => isIpsecManagedComment(i.comment))
const managedModeConfigs = current.modeConfigs.filter(isManagedIpsecModeConfig)
const managedPools = current.pools.filter((p) => isIpsecManagedComment(p.comment))
const managedNat = current.nat.filter((n) => isIpsecManagedComment(n.comment))
for (const want of desired.nat) {
const live = managedNat.find(n => n.comment === want.comment)
if (live && ((live.outInterface && !want.outInterface) || (live.outInterfaceList && !want.outInterfaceList))) throw new IpsecRestoreConflict("Откат требует снять прежнее ограничение интерфейса NAT. Снимите его вручную перед повтором")
}
const removedPeers = managedPeers.filter(p => !desired.peers.some(w => w.name === p.name))
const removedConfigs = managedModeConfigs.filter(m => !desired.modeConfigs.some(w => w.name === m.name))
+7 -5
View File
@@ -1,20 +1,22 @@
import type { MikrotikClient } from "./mikrotik.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, isManagedIpsecModeConfig, isIpsecManagedComment } from "./ipsec-config.js"
import { IPSEC_CA_CERT, IPSEC_SERVER_CERT, IPSEC_POLICY_GROUP, 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"]
const paths = ["/ip/ipsec/identity", "/ip/ipsec/peer", "/ip/ipsec/policy", "/ip/ipsec/mode-config", "/ip/pool", "/ip/firewall/nat", "/ip/ipsec/policy/group"]
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 linkedGroups = new Set(snapshots.find(s => s.path === "/ip/ipsec/policy")?.rows.filter(r => isIpsecManagedComment(r.comment)).map(r => r.group))
const owned = (path: string, row: Record<string, string | undefined>) => path === "/ip/ipsec/policy/group" ? row.name === IPSEC_POLICY_GROUP && linkedGroups.has(row.name) : 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")
const peerNames = names("/ip/ipsec/peer"), modeConfigs = names("/ip/ipsec/mode-config"), certificates = names("/certificate"), groups = names("/ip/ipsec/policy/group")
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/identity" && (groups.has(row["policy-template-group"] ?? "") || 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")
if (path === "/ip/ipsec/policy" && row.dynamic !== "true" && row.dynamic !== "yes" && groups.has(row.group ?? "")) throw new Error("Удаление заблокировано: группа используется чужой policy")
}
}
for (const { path, rows } of snapshots) {
+2
View File
@@ -43,6 +43,7 @@ export interface Ike2IdentityLike extends IdentityLike {
certificate?: string
"auth-method"?: string
"mode-config"?: string
"policy-template-group"?: string
}
/** Peer (/ip/ipsec/peer). */
@@ -95,6 +96,7 @@ export const IPSEC_CA_CERT = "ipsec-ca"
export const IPSEC_SERVER_CERT = "ipsec-server"
/** Общее имя peer / profile / proposal / пула / общего mode-config. */
export const IPSEC_COMMON_NAME = "ipsec-vpn"
export const IPSEC_POLICY_GROUP = "mmapp-ipsec-vpn"
export function ipsecSlug(name: string): string {
return name
+12
View File
@@ -63,6 +63,7 @@ export interface RosIpsecIdentity {
"remote-id"?: string
"mode-config"?: string
"generate-policy"?: string
"policy-template-group"?: string
disabled?: string
comment?: string
}
@@ -102,6 +103,9 @@ export interface RosFirewallNat {
action?: string
"src-address"?: string
comment?: string
"out-interface"?: string
"out-interface-list"?: string
"ipsec-policy"?: string
}
export interface RosIpsecActivePeer {
@@ -456,6 +460,7 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
policyTemplateGroup: i["policy-template-group"],
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
@@ -489,6 +494,9 @@ export async function captureIpsecSnapshot(server: ServerRow): Promise<IpsecSnap
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
outInterface: n["out-interface"],
outInterfaceList: n["out-interface-list"],
ipsecPolicy: n["ipsec-policy"],
comment: (n.comment ?? "").trim(),
})),
})
@@ -534,6 +542,7 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
remoteId: (i["remote-id"] ?? "").trim(),
modeConfig: (i["mode-config"] ?? "").trim(),
generatePolicy: (i["generate-policy"] ?? "").trim(),
policyTemplateGroup: i["policy-template-group"],
comment: (i.comment ?? "").trim(),
disabled: asBool(i.disabled),
})),
@@ -559,6 +568,9 @@ export async function fetchIpsecRestoreState(server: ServerRow): Promise<{
chain: (n.chain ?? "").trim(),
action: (n.action ?? "").trim(),
srcAddress: (n["src-address"] ?? "").trim(),
outInterface: n["out-interface"],
outInterfaceList: n["out-interface-list"],
ipsecPolicy: n["ipsec-policy"],
comment: (n.comment ?? "").trim(),
})),
snapshot: await captureIpsecSnapshot(server),
@@ -27,3 +27,11 @@ assert.throws(() => planIpsecRestore(canonicalIpsecSnapshot({}), { ...base, poli
const foreignPool = { ...canonicalIpsecSnapshot({ pools: [{ name: peer.name, comment: "foreign" }] }).pools[0]!, rosId: "*FOREIGNPOOL" }
assert.throws(() => planIpsecRestore(canonicalIpsecSnapshot({ pools: [{ name: peer.name, comment: "MikrotikManager:ipsec" }] }), { ...base, pools: [foreignPool] }), /чужой конфигурацией/)
console.log("IPsec restore safety: foreign identity/policy references and name conflict fail before operations")
const scopedNat = canonicalIpsecSnapshot({nat:[{chain:"srcnat",action:"masquerade",srcAddress:"198.18.82.0/24",outInterface:"ether1",ipsecPolicy:"out,none",comment:"MikrotikManager:ipsec NAT"}]})
const scopedOps = planIpsecRestore(scopedNat,{peers:[],identities:[],modeConfigs:[],pools:[],nat:[]})
const natOp=scopedOps.find(o=>o.op==="put"&&o.path==="/ip/firewall/nat")
assert(natOp&&"body" in natOp&&natOp.body["out-interface"]==="ether1"&&natOp.body["ipsec-policy"]==="out,none")
assert.throws(()=>planIpsecRestore(canonicalIpsecSnapshot({nat:[{chain:"srcnat",action:"masquerade",srcAddress:"198.18.82.0/24",comment:"MikrotikManager:ipsec NAT"}]}),{peers:[],identities:[],modeConfigs:[],pools:[],nat:[]}),/Ревизия NAT/)
console.log("IPsec NAT history: retains egress/policy, rejects legacy unscoped masquerade")
assert.throws(()=>planIpsecRestore(scopedNat,{peers:[],identities:[],modeConfigs:[],pools:[],nat:[{...scopedNat.nat[0]!,outInterface:undefined,outInterfaceList:"WAN",rosId:"*N"}]}),/Снимите его вручную/)
assert(natOp&&"body" in natOp&&natOp.body["out-interface-list"]===undefined,"Never send empty ambiguous RouterOS list value")
+61 -8
View File
@@ -1,5 +1,7 @@
import type { MikrotikClient } from "./mikrotik.js"
import { normalizeIpsecRemoteId,
IPSEC_POLICY_GROUP,
isIpsecManagedComment,
ipsecManagedComment,
isManagedIpsecModeConfig,
selectIke2Peers,
@@ -104,6 +106,7 @@ export async function ensurePolicyTemplate(
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
group: IPSEC_POLICY_GROUP,
comment,
}))
return
@@ -112,11 +115,24 @@ export async function ensurePolicyTemplate(
"src-address": "0.0.0.0/0",
"dst-address": dstCidr,
proposal: proposalName,
group: IPSEC_POLICY_GROUP,
template: "yes",
comment,
}))
}
export class IpsecPolicyGroupConflict extends Error {}
/** Reserved group reuse requires a managed template and no foreign consumers. */
export async function ipsecPolicyGroupExists(client: MikrotikClient): Promise<boolean> {
const groups = await listByPath(client, "/ip/ipsec/policy/group")
if (!groups.some(g => g.name === IPSEC_POLICY_GROUP)) return false
const [policies, identities] = await Promise.all([listByPath(client, "/ip/ipsec/policy"), listByPath(client, "/ip/ipsec/identity")])
if (!policies.some(p => p.group === IPSEC_POLICY_GROUP && isIpsecManagedComment(p.comment))) throw new IpsecPolicyGroupConflict("Зарезервированное имя группы политик занято объектом без подтверждённого управления")
if (policies.some(p => p.group === IPSEC_POLICY_GROUP && !isIpsecManagedComment(p.comment) && p.dynamic !== "true" && p.dynamic !== "yes") || identities.some(i => i["policy-template-group"] === IPSEC_POLICY_GROUP && !isIpsecManagedComment(i.comment))) throw new IpsecPolicyGroupConflict("Группа политик используется чужой конфигурацией")
return true
}
export async function ensureIpsecPeer(
client: MikrotikClient,
name: string,
@@ -134,20 +150,44 @@ export async function ensureIpsecPeer(
}))
}
/** Managed srcnat masquerade: интернет клиентам VPN. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise<void> {
export class IpsecNatInterfaceConflict extends Error {}
export function selectIpsecNatInterface(routes: RosRow[], interfaces: RosRow[]): string {
const candidates = new Set<string>()
for (const route of routes) {
if (route["dst-address"] !== "0.0.0.0/0" || !["true", "yes"].includes(route.active ?? "") || (route["routing-table"] && route["routing-table"] !== "main")) continue
const hops = (route["immediate-gw"] ?? route.gateway ?? "").split(",")
for (const hop of hops) {
const name = hop.includes("%") ? hop.slice(hop.lastIndexOf("%") + 1).trim() : hop.trim()
if (interfaces.some(i => i.name === name && !["true", "yes"].includes(i.disabled ?? ""))) candidates.add(name)
}
}
if (candidates.size !== 1) throw new IpsecNatInterfaceConflict("Не удалось однозначно определить исходящий интерфейс по активному маршруту main. Отключите NAT в мастере и настройте правило вручную")
return [...candidates][0]!
}
export async function resolveIpsecNatInterface(client: MikrotikClient): Promise<string> {
const [routes, interfaces] = await Promise.all([listByPath(client, "/ip/route"), listByPath(client, "/interface")])
return selectIpsecNatInterface(routes, interfaces)
}
/** Managed srcnat masquerade only through the verified default-route egress. */
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string, outInterface: string): Promise<void> {
const rows = await listByPath(client, "/ip/firewall/nat")
const existing = rows.find(
(r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat",
)
if (existing?.[".id"]) return
await client.put("/ip/firewall/nat", toRosBody({
const body = toRosBody({
chain: "srcnat",
action: "masquerade",
"src-address": poolCidr,
"out-interface-list": "WAN",
"out-interface": outInterface,
"ipsec-policy": "out,none",
comment,
}))
})
if (existing?.[".id"]) {
await client.patch(`/ip/firewall/nat/${encodeURIComponent(existing[".id"])}`, body)
} else await client.put("/ip/firewall/nat", body)
}
// ── клиенты ─────────────────────────────────────────────────────────────────
@@ -187,6 +227,7 @@ export interface IdentityFields {
remoteId?: string
/** Legacy hint: RouterOS identities do not accept place-before; specificity uses match-by/remote-certificate. */
placeBefore?: string
policyTemplateGroup?: string
}
export function identityRosBody(f: IdentityFields): Record<string, string> {
@@ -200,11 +241,16 @@ export function identityRosBody(f: IdentityFields): Record<string, string> {
"remote-id": f.remoteId === undefined ? undefined : normalizeIpsecRemoteId(f.remoteId),
"mode-config": f.modeConfig,
"generate-policy": "port-strict",
"policy-template-group": f.policyTemplateGroup,
comment: f.comment,
})
}
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
if (!fields.policyTemplateGroup) {
const shared = selectSharedIke2Identity(await listByPath(client, "/ip/ipsec/identity"), [fields.peerName])
fields = { ...fields, policyTemplateGroup: shared?.["policy-template-group"] }
}
await client.put("/ip/ipsec/identity", identityRosBody(fields))
}
@@ -214,7 +260,7 @@ export async function putIdentity(client: MikrotikClient, fields: IdentityFields
*/
export async function ensureSharedIke2Identity(
client: MikrotikClient,
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string },
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string; policyTemplateGroup?: string },
): Promise<string | undefined> {
const identities = await listByPath(client, "/ip/ipsec/identity")
const peers = await listByPath(client, "/ip/ipsec/peer")
@@ -223,7 +269,13 @@ export async function ensureSharedIke2Identity(
identities as Ike2IdentityLike[],
peerNames.length > 0 ? peerNames : [args.peerName],
)
if (existing?.[".id"]) return String(existing[".id"])
if (existing?.[".id"]) {
if (args.policyTemplateGroup && existing["policy-template-group"] !== args.policyTemplateGroup) {
if (!isIpsecManagedComment(existing.comment)) throw new Error("Нельзя изменить группу политик чужой listener identity")
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(existing[".id"])}`, { "policy-template-group": args.policyTemplateGroup })
}
return String(existing[".id"])
}
await putIdentity(client, {
peerName: args.peerName,
@@ -231,6 +283,7 @@ export async function ensureSharedIke2Identity(
comment: args.comment,
authMethod: "certificate",
certificate: args.serverCertName,
policyTemplateGroup: args.policyTemplateGroup,
})
return undefined
}
+3 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5
Статус на2026-10-09: backend ux-ipsec81/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 восстановлен.
Статус на2026-10-09: backend ux-ipsec82c/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 восстановлен.
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
@@ -10,6 +10,8 @@ IPsec80: real restore staticIp/disabled, повтор без дубликато
IPsec81: partial/unknown native fault injection, preflight конфликтов, typed Remote ID, удаление старого mode-config и isolated JWT/RBAC приняты. Рабочая авторизация не менялась, физический TCP disconnect не моделировался. [Отчёт](ux-ui/acceptance/ipsec-mutations-2026-10-09.json).
IPsec82: production certificate data plane/NAT Ping3/3, capped UDP3s, history group/egress и API server delete приняты. Baseline14 коллекций×2 восстановлен после route convergence; own/private fixtures удалены. [Отчёт](ux-ui/acceptance/ipsec-network-2026-10-09.json).
## Паспорт стенда
Перед внешним запуском зафиксировать отдельные PostgreSQL и RouterOS тестовые устройства, версии server/client PostgreSQL и RouterOS, HEAD backend/frontend, роли пользователей, число экземпляров backend/планировщика/отправителей и способ их остановки. Для Telegram использовать тестовый чат, для остальных интеграций — тестовые endpoints и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда.
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-09: 12.5.81 внедрил исходы partial/unknown/applied, preflight конфликтов и очистку старого mode-config; native/API fault injection и isolated JWT/RBAC приняты. Backend ux-ipsec81 healthy, frontend ux-ipsec77; 7 профильных E2E прошли. Следующий12.5.82 — NAT/связность сертификатного клиента; остальные границы IPsec перечислены ниже. Этап12.5 открыт.
Актуальное продолжение 2026-10-09: 12.5.82 исправил NAT без обязательного WAN, изолировал IPsec policy group и сохранил ограничения NAT/группы в истории. Production API init/client/restore/delete, сертификатный data plane, NAT Ping3/3 и capped bandwidth3s приняты; baseline восстановлен после ожидания динамических маршрутов. Backend ux-ipsec82c healthy, frontend ux-ipsec77. Следующий12.5.83 — BGP/OSPF selected/diagnostic paths: запрет опроса выключенных устройств и достоверность ошибок; затем реальные профильные сценарии маршрутизации. Этап12.5 открыт.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-09. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+28 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-09: 12.5.81 внедрил исходы partial/unknown/applied, preflight конфликтов и очистку старого mode-config; native/API fault injection и isolated JWT/RBAC приняты. Backend ux-ipsec81 healthy, frontend ux-ipsec77; 7 профильных E2E прошли. Следующий12.5.82 — NAT/связность сертификатного клиента; остальные границы IPsec перечислены ниже. Этап12.5 открыт.
Актуальное продолжение 2026-10-09: 12.5.82 исправил NAT без обязательного WAN, изолировал IPsec policy group и сохранил ограничения NAT/группы в истории. Production API init/client/restore/delete, сертификатный data plane, NAT Ping3/3 и capped bandwidth3s приняты; baseline восстановлен после ожидания динамических маршрутов. Backend ux-ipsec82c healthy, frontend ux-ipsec77. Следующий12.5.83 — BGP/OSPF selected/diagnostic paths: запрет опроса выключенных устройств и достоверность ошибок; затем реальные профильные сценарии маршрутизации. Этап12.5 открыт.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -36,7 +36,33 @@
| 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–81 и ограничения ниже. Runtime backend ux-ipsec81/frontend ux-ipsec77. |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–82 и ограничения ниже. Runtime backend ux-ipsec82c/frontend ux-ipsec77. |
## 12.5.82 — IPsec: NAT, policy group, реальная передача и полный цикл (2026-10-09)
**Исправления по native доказательствам.** Master NAT ссылался на несуществующий WAN list чистого CHR и получал400 после остальных записей. Теперь до изменений читаются маршруты/interfaces, выбирается единственный egress активных IPv4 defaults main; PPP/direct interface и дубликаты одного egress поддержаны, отсутствие/неоднозначность даёт409 без CA/других записей. Srcnat ограничен VPN-подсетью, конкретным out-interface и ipsec-policy=out,none. Повторный init обновляет одно owned правило; прежнее дополнительное interface-list ограничение не очищается вслепую.
Группа default выбирала несовместимый proposal: phase1 established/lease/online были успешны, data plane Ping0/3 и native log no proposal chosen. Изолированная группа в fixture доказала причину. Master теперь создаёт mmapp-ipsec-vpn, привязывает owned template/listener и новые персональные identities; legacy default/пустая группа owned identities на app peer мигрирует, чужие объекты/default policy сохраняются. Reuse reserved group требует managed template, foreign consumers блокируются до изменений. Delete server удаляет только reserved group со ссылкой managed template и без foreign refs.
**История.** Capture/canonical/body сохраняют policy-template-group и NAT out-interface/out-interface-list/ipsec-policy. Restore проверяет наличие группы и не теряет egress. Старый masquerade snapshot без egress отклоняется409. RouterOS также отклонил пустую out-interface-list как ambiguous: body больше не отправляет пустые ограничения, несовместимое снятие существующего дополнительного фильтра блокируется до записи с инструкцией ручной проверки. PKI/profile/proposal/policy definitions по-прежнему не пересоздаются историей.
| Контроль | Исходная проблема | Подтверждённый результат |
| --- | --- | --- |
| NAT master | WAN отсутствует,400 после partial init | ether1/out,none; repeat init одно правило |
| Data plane | default proposal, Ping0/3 | owned policy group; Ping3/3 в обе стороны |
| Интернет | До группы не проверен | CHR2 assigned198.18.82.250→8.8.8.8 Ping3/3, srcnat=true/seen-reply=true |
| Restore/delete | Пустое interface-list400 | группа/egress/disabled восстановлены; group удалена API, default policy сохранена |
| Cleanup | Преждевременная проверка route IDs | контроль после удаления обоих peer и короткого bounded ожидания: все14 коллекций ×2 совпали |
**Приёмка.** Собственный сертификатный клиент API, PKCS12 import наCHR2; dynamic assigned address применяется RouterOS, отдельный дублирующий IP наCHR2 больше не добавляется. НаCHR1 собственный bridge198.18.82.1/32; отдельные маршруты только между своими IP с pref-src через неизменённые public gateways. Production init/client/GET/restore/DELETE server прошли без ручной модификации группы в финальном сценарии. Capped UDP bandwidth3s,1,000,000bps в каждую сторону: tx-total-average955040/rx970080bps, lost-packets0; encrypted SA byte delta816906. Это smoke передачи, не максимальная пропускная способность/SLA. NAT reply destination194.226.121.150 подтвердил masquerade. [Очищенный отчёт с baseline/recheck](ux-ui/acceptance/ipsec-network-2026-10-09.json).
**Проверки.** test:ipsec: config/80 state checks/history/restore/progress/mutation/NAT/group, entity-snapshots tests, backend TypeScript/ESLint/diff check. Новые HTTP/unit проверки: no-write missing/ambiguous/VRF NAT, shared group inheritance, foreign refs/namespace guard, default group retained, scoped/legacy NAT restore. Исходный unused bool warning сохраняется. Frontend не изменён;7 E2E81 остаются актуальным profile evidence, полная регрессия не повторялась.
**Ошибки тестовой оснастки и очистка.** После ручного proof динамическая policy была ошибочно принята за новый чужой объект; cleanup остановился. Read-only ownership audit подтвердил точные29 собственных ID/атрибутов на двух CHR; они удалены после полного preflight, dynamic policy/assignedIP исчезли с peer, приватные файлы удалены. Receipt теперь пропускает auto policy/address, а статические конфигурации удаляет по точным проверенным ID. Один финальный run имел только transient route mismatch; независимый post-control подтвердил исходные4/5 маршрутов, повторный окончательный run сохранил baseline и recheck: CHR1 initialMatch=false/finalMatch=true, CHR2true/true. Не подменять этот переход утверждением о постоянном изменении ID. Flash/system files,195.195.195.195/32, default routes сохранены; пароли прежние.
**Runtime/происхождение.** Backend mmapp-test/backend:ux-ipsec82c/version1.0.0-test.ipsec82c, image sha256:57fdd7dbbf66091fd0710154ed7c47f284047505977e7b06d0b15bfbfd5fa57d3, healthy; frontend ux-ipsec77. Три builds82/82b/82c по новым подтверждённым отказам: NAT/WAN, proposal group, empty list restore. Финальный context82b +11 backend файлов рабочего состояния, archive d7b0ad10c248ca6c1525266d67b4953f2752f5d046d46b63262e3dc93421e280, label ipsec82c не итоговый Git SHA. Entry/node --check/pg_restore18.6 и compose --wait passed; apt cached. Приватные .env backups, миграций нет. Один локальный коммит без push.
Основной реальный цикл IPsec принят в перечисленных границах. Открыты сложные/экранированные DN, реальные клиентские ОС/IPv6, Portal SSO/browser auth, физические disconnect/durable retry/multi-instance, maximum throughput и полный PKI/policy/profile/proposal restore. Следующий83 — маршрутизация. Референсы интерфейса сохраняются: List9/Sheet8 из общего плана; настройки полей подтверждены [RouterOS IPsec](https://help.mikrotik.com/docs/spaces/ROS/pages/11993097/IPsec) и [Bandwidth Test](https://help.mikrotik.com/docs/spaces/ROS/pages/7962644/Bandwidth%2BTest).
## 12.5.81 — IPsec: частичные операции, потерянное подтверждение и права (2026-10-09)
+26
View File
@@ -1942,3 +1942,29 @@ flowchart LR
**Очистка и runtime.** До удаления сверены точные ID и ожидаемые имена/comments; свои objects удалены, baseline IDs четырёх коллекций совпали. Один backend build/deploy: mmapp-test/backend:ux-ipsec81/version1.0.0-test.ipsec81, image sha256:2a7d8ab317867f2df4826b7c477659eeba689b7e9d8806676ca030fc4f099abac, healthy; frontend ux-ipsec77. Entry file/node --check/pg_restore18.6 и compose --wait прошли. Runner apt layer #18 CACHED: оптимизация80 подтверждена на изменённом коде/версии. Context ipsec80 + восемь backend-файлов до коммита, archive SHA111eb30a5621eb85e2f7e421a25c22ab70f8073093a83cf0a0f67663163004ec; label ipsec81, не Git SHA. .env backup приватный; миграций нет. Один локальный коммит без push.
Следующий12.5.82: реальная NAT/связность сертификатного клиента. Открыты физические transport disconnect/durable retry, multi-instance, Portal SSO/browser auth, IPv6/клиентские ОС/throughput, сложные DN и полный PKI/policy/profile/proposal restore. Не расширять эти границы по результатам fault injection.
## 12.5.82 — IPsec: NAT, policy group, реальная передача и полный цикл (2026-10-09)
**Исправления по native доказательствам.** Master NAT ссылался на несуществующий WAN list чистого CHR и получал400 после остальных записей. Теперь до изменений читаются маршруты/interfaces, выбирается единственный egress активных IPv4 defaults main; PPP/direct interface и дубликаты одного egress поддержаны, отсутствие/неоднозначность даёт409 без CA/других записей. Srcnat ограничен VPN-подсетью, конкретным out-interface и ipsec-policy=out,none. Повторный init обновляет одно owned правило; прежнее дополнительное interface-list ограничение не очищается вслепую.
Группа default выбирала несовместимый proposal: phase1 established/lease/online были успешны, data plane Ping0/3 и native log no proposal chosen. Изолированная группа в fixture доказала причину. Master теперь создаёт mmapp-ipsec-vpn, привязывает owned template/listener и новые персональные identities; legacy default/пустая группа owned identities на app peer мигрирует, чужие объекты/default policy сохраняются. Reuse reserved group требует managed template, foreign consumers блокируются до изменений. Delete server удаляет только reserved group со ссылкой managed template и без foreign refs.
**История.** Capture/canonical/body сохраняют policy-template-group и NAT out-interface/out-interface-list/ipsec-policy. Restore проверяет наличие группы и не теряет egress. Старый masquerade snapshot без egress отклоняется409. RouterOS также отклонил пустую out-interface-list как ambiguous: body больше не отправляет пустые ограничения, несовместимое снятие существующего дополнительного фильтра блокируется до записи с инструкцией ручной проверки. PKI/profile/proposal/policy definitions по-прежнему не пересоздаются историей.
| Контроль | Исходная проблема | Подтверждённый результат |
| --- | --- | --- |
| NAT master | WAN отсутствует,400 после partial init | ether1/out,none; repeat init одно правило |
| Data plane | default proposal, Ping0/3 | owned policy group; Ping3/3 в обе стороны |
| Интернет | До группы не проверен | CHR2 assigned198.18.82.250→8.8.8.8 Ping3/3, srcnat=true/seen-reply=true |
| Restore/delete | Пустое interface-list400 | группа/egress/disabled восстановлены; group удалена API, default policy сохранена |
| Cleanup | Преждевременная проверка route IDs | контроль после удаления обоих peer и короткого bounded ожидания: все14 коллекций ×2 совпали |
**Приёмка.** Собственный сертификатный клиент API, PKCS12 import наCHR2; dynamic assigned address применяется RouterOS, отдельный дублирующий IP наCHR2 больше не добавляется. НаCHR1 собственный bridge198.18.82.1/32; отдельные маршруты только между своими IP с pref-src через неизменённые public gateways. Production init/client/GET/restore/DELETE server прошли без ручной модификации группы в финальном сценарии. Capped UDP bandwidth3s,1,000,000bps в каждую сторону: tx-total-average955040/rx970080bps, lost-packets0; encrypted SA byte delta816906. Это smoke передачи, не максимальная пропускная способность/SLA. NAT reply destination194.226.121.150 подтвердил masquerade. [Очищенный отчёт с baseline/recheck](ux-ui/acceptance/ipsec-network-2026-10-09.json).
**Проверки.** test:ipsec: config/80 state checks/history/restore/progress/mutation/NAT/group, entity-snapshots tests, backend TypeScript/ESLint/diff check. Новые HTTP/unit проверки: no-write missing/ambiguous/VRF NAT, shared group inheritance, foreign refs/namespace guard, default group retained, scoped/legacy NAT restore. Исходный unused bool warning сохраняется. Frontend не изменён;7 E2E81 остаются актуальным profile evidence, полная регрессия не повторялась.
**Ошибки тестовой оснастки и очистка.** После ручного proof динамическая policy была ошибочно принята за новый чужой объект; cleanup остановился. Read-only ownership audit подтвердил точные29 собственных ID/атрибутов на двух CHR; они удалены после полного preflight, dynamic policy/assignedIP исчезли с peer, приватные файлы удалены. Receipt теперь пропускает auto policy/address, а статические конфигурации удаляет по точным проверенным ID. Один финальный run имел только transient route mismatch; независимый post-control подтвердил исходные4/5 маршрутов, повторный окончательный run сохранил baseline и recheck: CHR1 initialMatch=false/finalMatch=true, CHR2true/true. Не подменять этот переход утверждением о постоянном изменении ID. Flash/system files,195.195.195.195/32, default routes сохранены; пароли прежние.
**Runtime/происхождение.** Backend mmapp-test/backend:ux-ipsec82c/version1.0.0-test.ipsec82c, image sha256:57fdd7dbbf66091fd0710154ed7c47f284047505977e7b06d0b15bfbfd5fa57d3, healthy; frontend ux-ipsec77. Три builds82/82b/82c по новым подтверждённым отказам: NAT/WAN, proposal group, empty list restore. Финальный context82b +11 backend файлов рабочего состояния, archive d7b0ad10c248ca6c1525266d67b4953f2752f5d046d46b63262e3dc93421e280, label ipsec82c не итоговый Git SHA. Entry/node --check/pg_restore18.6 и compose --wait passed; apt cached. Приватные .env backups, миграций нет. Один локальный коммит без push.
Основной реальный цикл IPsec принят в перечисленных границах. Открыты сложные/экранированные DN, реальные клиентские ОС/IPv6, Portal SSO/browser auth, физические disconnect/durable retry/multi-instance, maximum throughput и полный PKI/policy/profile/proposal restore. Следующий83 — маршрутизация. Референсы интерфейса сохраняются: List9/Sheet8 из общего плана; настройки полей подтверждены [RouterOS IPsec](https://help.mikrotik.com/docs/spaces/ROS/pages/11993097/IPsec) и [Bandwidth Test](https://help.mikrotik.com/docs/spaces/ROS/pages/7962644/Bandwidth%2BTest).
+4 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-09
Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-ipsec81/version1.0.0-test.ipsec81, 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 доступны, пароли прежние.
Сервер161.104.46.222 предоставлен для временного тестирования. Backend ux-ipsec82c/version1.0.0-test.ipsec82c, 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-ipsec81 и mmapp-test/frontend:ux-ipsec77; запущены |
| Образы приложения | mmapp-test/backend:ux-ipsec82c и mmapp-test/frontend:ux-ipsec77; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -26,6 +26,8 @@ IPsec80: real restore staticIp/disabled, повтор без дубликато
IPsec81: partial/unknown native fault injection, preflight конфликтов, typed Remote ID, удаление старого mode-config и isolated JWT/RBAC приняты. Рабочая авторизация не менялась, физический TCP disconnect не моделировался. [Отчёт](ux-ui/acceptance/ipsec-mutations-2026-10-09.json).
IPsec82: production certificate data plane/NAT Ping3/3, capped UDP3s, history group/egress и API server delete приняты. Baseline14 коллекций×2 восстановлен после route convergence; own/private fixtures удалены. [Отчёт](ux-ui/acceptance/ipsec-network-2026-10-09.json).
## Развёртывание приложения
Чистые frontend/backend build contexts подготовлены по .gitea/workflows/docker.yml из закреплённого Git HEAD;образы собраны на сервере без push. Локально docker CLI сейчас отсутствует, на сервере доступен Docker. В корневом .dockerignore исключён backend, поэтому обычный backend build из корня не повторяет CI: использовать отдельный backend context, как main workflow. Не передавать .env.local,backend/.env,ключи и локальные node_modules/.next.
@@ -0,0 +1,336 @@
{
"date": "2026-10-09",
"name": "mm-ipsec82-1791540186150",
"observations": [
{
"serverId": 1,
"active": [
{
".id": "*1",
"id": "CN=mm-ipsec82-1791540186150",
"state": "established",
"dynamic-address": "198.18.82.250",
"remote-address": "104.165.244.7",
"uptime": "0s"
}
],
"catalog": [
{
"name": "mm-ipsec82-1791540186150",
"commonName": "mm-ipsec82-1791540186150",
"staticIp": "198.18.82.250",
"online": true,
"activeAddress": "198.18.82.250",
"activeSince": "0s"
}
]
},
{
"serverId": 2,
"active": [
{
".id": "*1",
"id": "194.226.121.150",
"state": "established",
"remote-address": "194.226.121.150",
"uptime": "0s"
}
],
"catalog": []
}
],
"cleanup": [
{
"serverId": 1,
"/ip/ipsec/identity": true,
"/ip/ipsec/peer": true,
"/ip/ipsec/policy": true,
"/ip/ipsec/policy/group": true,
"/ip/ipsec/mode-config": true,
"/ip/pool": true,
"/ip/ipsec/proposal": true,
"/ip/ipsec/profile": true,
"/certificate": true,
"/file": true,
"/interface/bridge": true,
"/ip/address": true,
"/ip/route": true,
"/ip/firewall/nat": true
},
{
"serverId": 2,
"/ip/ipsec/identity": true,
"/ip/ipsec/peer": true,
"/ip/ipsec/policy": true,
"/ip/ipsec/policy/group": true,
"/ip/ipsec/mode-config": true,
"/ip/pool": true,
"/ip/ipsec/proposal": true,
"/ip/ipsec/profile": true,
"/certificate": true,
"/file": true,
"/interface/bridge": true,
"/ip/address": true,
"/ip/route": true,
"/ip/firewall/nat": true
}
],
"baselineRoutes": [
{
"serverId": 1,
"rows": [
{
".id": "*80000003",
"dst-address": "0.0.0.0/0",
"gateway": "194.226.121.1",
"routing-table": "main",
"dynamic": "false"
},
{
".id": "*80000004",
"dst-address": "0.0.0.0/0",
"gateway": "194.226.121.1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*80000005",
"dst-address": "169.254.169.254/32",
"gateway": "194.226.121.1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*20145010",
"dst-address": "194.226.121.0/24",
"gateway": "ether1",
"routing-table": "main",
"dynamic": "true"
}
]
},
{
"serverId": 2,
"rows": [
{
".id": "*80000003",
"dst-address": "0.0.0.0/0",
"gateway": "104.165.244.1",
"routing-table": "main",
"dynamic": "false"
},
{
".id": "*80000005",
"dst-address": "0.0.0.0/0",
"gateway": "104.165.244.1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*20145020",
"dst-address": "104.165.244.0/24",
"gateway": "ether1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*80000004",
"dst-address": "169.254.169.254/32",
"gateway": "104.165.244.2",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*20145010",
"dst-address": "195.195.195.195/32",
"gateway": "ether1",
"routing-table": "main",
"dynamic": "true"
}
]
}
],
"natRule": {
"outInterface": "ether1",
"ipsecPolicy": "out,none",
"noWanListRequired": true,
"repeatInitOneRule": true
},
"expectedAddress": "198.18.82.250",
"staticLeaseCorrect": true,
"catalogCorrect": true,
"pings": [
{
"serverId": 2,
"address": "198.18.82.1",
"source": "198.18.82.250",
"received": 3,
"sent": 3
},
{
"serverId": 1,
"address": "198.18.82.250",
"source": "198.18.82.1",
"received": 3,
"sent": 3
},
{
"serverId": 2,
"address": "8.8.8.8",
"source": "198.18.82.250",
"received": 3,
"sent": 3
}
],
"natEvidence": [
{
"src-address": "198.18.82.250",
"dst-address": "8.8.8.8",
"reply-src-address": "8.8.8.8",
"reply-dst-address": "194.226.121.150",
"srcnat": "true",
"seen-reply": "true"
}
],
"bandwidth": {
"durationSec": 3,
"capBitsPerSecondPerDirection": 1000000,
"protocol": "udp",
"source": "assigned client via own pref-src route",
"rows": [
{
"status": "connecting",
"tx-current": "0",
"rx-current": "0",
"tx-total-average": "0",
"rx-total-average": "0",
"lost-packets": "0"
},
{
"status": "running",
"duration": "0s",
"tx-current": "0",
"rx-current": "0",
"tx-total-average": "0",
"rx-total-average": "0",
"lost-packets": "0"
},
{
"status": "running",
"duration": "1s",
"tx-current": "913680",
"rx-current": "970080",
"tx-total-average": "913680",
"rx-total-average": "970080",
"lost-packets": "0"
},
{
"status": "running",
"duration": "2s",
"tx-current": "981360",
"rx-current": "970080",
"tx-total-average": "947520",
"rx-total-average": "970080",
"lost-packets": "0"
},
{
"status": "done testing",
"duration": "3s",
"tx-current": "970080",
"rx-current": "970080",
"tx-total-average": "955040",
"rx-total-average": "970080",
"lost-packets": "0"
}
]
},
"encryptedByteDelta": 816906,
"historyRestore": {
"groupsPreserved": true,
"natEgressPreserved": true,
"clientEnabled": true
},
"serverDelete": {
"ownedGroupRemoved": true,
"defaultPolicyPreserved": true
},
"routeRecheck": [
{
"serverId": 1,
"initialMatch": false,
"finalMatch": true,
"rows": [
{
".id": "*80000003",
"dst-address": "0.0.0.0/0",
"gateway": "194.226.121.1",
"routing-table": "main",
"dynamic": "false"
},
{
".id": "*80000004",
"dst-address": "0.0.0.0/0",
"gateway": "194.226.121.1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*80000005",
"dst-address": "169.254.169.254/32",
"gateway": "194.226.121.1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*20145010",
"dst-address": "194.226.121.0/24",
"gateway": "ether1",
"routing-table": "main",
"dynamic": "true"
}
]
},
{
"serverId": 2,
"initialMatch": true,
"finalMatch": true,
"rows": [
{
".id": "*80000003",
"dst-address": "0.0.0.0/0",
"gateway": "104.165.244.1",
"routing-table": "main",
"dynamic": "false"
},
{
".id": "*80000005",
"dst-address": "0.0.0.0/0",
"gateway": "104.165.244.1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*20145020",
"dst-address": "104.165.244.0/24",
"gateway": "ether1",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*80000004",
"dst-address": "169.254.169.254/32",
"gateway": "104.165.244.2",
"routing-table": "main",
"dynamic": "true"
},
{
".id": "*20145010",
"dst-address": "195.195.195.195/32",
"gateway": "ether1",
"routing-table": "main",
"dynamic": "true"
}
]
}
]
}