diff --git a/backend/package.json b/backend/package.json index 884f555b..8c64f88c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/ipsec-group.test.ts b/backend/src/routes/ipsec-group.test.ts new file mode 100644 index 00000000..9cf972e4 --- /dev/null +++ b/backend/src/routes/ipsec-group.test.ts @@ -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|undefined,templateBody:Record|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)=>{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)=>{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[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") diff --git a/backend/src/routes/ipsec-nat.test.ts b/backend/src/routes/ipsec-nat.test.ts new file mode 100644 index 00000000..bbc8215d --- /dev/null +++ b/backend/src/routes/ipsec-nat.test.ts @@ -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[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|undefined;let method="";await ensureNatRule({get:async()=>existing?[{".id":"*N",chain:"srcnat",comment:"owned","out-interface-list":"WAN"}]:[],put:async(_p:string,b:Record)=>{method="put";body=b},patch:async(_p:string,b:Record)=>{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") + diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index 0dbbb94a..db71ef96 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -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("/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("/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, diff --git a/backend/src/services/entity-snapshots.test.ts b/backend/src/services/entity-snapshots.test.ts index 4a2a672e..74474e2a 100644 --- a/backend/src/services/entity-snapshots.test.ts +++ b/backend/src/services/entity-snapshots.test.ts @@ -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))) diff --git a/backend/src/services/entity-snapshots.ts b/backend/src/services/entity-snapshots.ts index e520967b..0ef0cf8c 100644 --- a/backend/src/services/entity-snapshots.ts +++ b/backend/src/services/entity-snapshots.ts @@ -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): 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 { "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 { } function ipsecNatBody(n: IpsecSnapshotNat): Record { - 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)) diff --git a/backend/src/services/ipsec-cleanup.ts b/backend/src/services/ipsec-cleanup.ts index 69ebeae0..84bd4967 100644 --- a/backend/src/services/ipsec-cleanup.ts +++ b/backend/src/services/ipsec-cleanup.ts @@ -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 { - 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) => 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) => 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) { diff --git a/backend/src/services/ipsec-config.ts b/backend/src/services/ipsec-config.ts index 57eb554f..1b2b5418 100644 --- a/backend/src/services/ipsec-config.ts +++ b/backend/src/services/ipsec-config.ts @@ -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 diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index d790a921..22bfe967 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -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 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") diff --git a/backend/src/services/ipsec-ros.ts b/backend/src/services/ipsec-ros.ts index b6e7990c..e2b21bc4 100644 --- a/backend/src/services/ipsec-ros.ts +++ b/backend/src/services/ipsec-ros.ts @@ -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 { + 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 { +export class IpsecNatInterfaceConflict extends Error {} + +export function selectIpsecNatInterface(routes: RosRow[], interfaces: RosRow[]): string { + const candidates = new Set() + 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 { + 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 { 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 { @@ -200,11 +241,16 @@ export function identityRosBody(f: IdentityFields): Record { "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 { + 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 { 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 } diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index dfd278fa..84c81b75 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -1,6 +1,6 @@ # UX/UI: протокол внешней приёмки 12.5 -Статус на2026-10-09: backend 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 и учётные записи. Зафиксировать ожидаемые изменения и способ восстановления стенда. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index f52839be..605c1241 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index edce8a5f..d2d893e9 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 3b19317c..9942284b 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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). diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 7513987c..f4fd92f7 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-09 -Сервер161.104.46.222 предоставлен для временного тестирования. Backend 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. diff --git a/docs/ux-ui/acceptance/ipsec-network-2026-10-09.json b/docs/ux-ui/acceptance/ipsec-network-2026-10-09.json new file mode 100644 index 00000000..792525d6 --- /dev/null +++ b/docs/ux-ui/acceptance/ipsec-network-2026-10-09.json @@ -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" + } + ] + } + ] +} \ No newline at end of file