diff --git a/backend/src/routes/ipsec-mutation.test.ts b/backend/src/routes/ipsec-mutation.test.ts index 91bd088d..35cf43f8 100644 --- a/backend/src/routes/ipsec-mutation.test.ts +++ b/backend/src/routes/ipsec-mutation.test.ts @@ -6,9 +6,9 @@ import { MikrotikClient, MikrotikError } from "../services/mikrotik.js" import { fetchIpsecState } from "../services/ipsec-live.js" const server = { id: 1, name: "test", host: "192.0.2.1", enabled: true } as Parameters[0] const original = MikrotikClient.fromServer -let writes = 0, records = 0, fault = "" -const identities = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }] -MikrotikClient.fromServer = () => ({ get: async (p: string) => p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient +let writes = 0, records = 0, fault = "", certificateFixture = false +const identities: Record[] = [{ ".id": "*I", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:alice", "mode-config": "mc-ipsec-alice", comment: "MikrotikManager:ipsec user=Alice" }, { ".id": "*OTHER", peer: "ike", "auth-method": "pre-shared-key", "remote-id": "key-id:other", "mode-config": "mc-ipsec-other", comment: "MikrotikManager:ipsec user=Other" }] +MikrotikClient.fromServer = () => ({ get: async (p: string) => certificateFixture && p === "/certificate" ? [{ ".id": "*CERT", name: "ipsec-user-shared", "common-name": "shared", "key-usage": "tls-client" }] : p === "/ip/ipsec/peer" ? [{ ".id": "*P", name: "ike", "exchange-mode": "ike2" }] : p === "/ip/ipsec/identity" ? identities : p === "/ip/ipsec/mode-config" ? [{ ".id": "*MC", name: "mc-ipsec-alice", address: "198.18.81.250" }, { ".id": "*OTHERMC", name: "mc-ipsec-other", address: "198.18.81.249" }] : [], put: async () => { writes++ }, post: async () => { writes++ }, delete: async () => { writes++ }, patch: async (path: string, body: Record) => { writes++; if (fault === "partial" && writes === 2) throw new MikrotikError(403, path, "rejected"); Object.assign(identities.find(i => path.endsWith(i[".id"]))!, body); if (fault === "lost-ack") throw Error("socket closed after application") } }) as unknown as MikrotikClient const app = Fastify(); app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) await app.register(createIpsecRoutes({ findServer: async () => server, recordRevision: async () => { records++; return {created:false,revision:{} as never} } }), { prefix: "/api" }) const patch = (payload: Record) => app.inject({ method: "PATCH", url: "/api/ipsec/users/1/identity:*I", payload }) @@ -17,6 +17,12 @@ for (const body of [{name:"New",staticIp:"198.18.81.249"},{name:"Other"},{remote writes=records=0; fault="partial"; let r=await patch({name:"New",psk:"new-test-secret"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"partial"); assert.equal(r.json().confirmedWrites,1); assert.equal(records,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=New") writes=records=0; fault="lost-ack"; r=await patch({name:"Unknown"}); assert.equal(r.statusCode,502); assert.equal(r.json().state,"unknown"); assert.equal(r.json().confirmedWrites,0); assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Unknown"); assert.equal(records,0) writes=records=0; fault=""; r=await patch({remoteId:"fresh"}); assert.equal(r.statusCode,200); assert.equal(identities[0]!["remote-id"],"key-id:fresh"); assert.equal(writes,1); assert.equal(records,1) + +certificateFixture=true;identities.splice(0,identities.length,...["*FIRST","*SECOND"].map((id,n)=>({".id":id,peer:"ike","auth-method":"digital-signature","remote-certificate":"ipsec-user-shared",comment:"MikrotikManager:ipsec user=Shared"+n}))); +for(const method of ["PATCH","DELETE"] as const){writes=records=0;const r=await app.inject({method,url:"/api/ipsec/users/1/cert:*CERT",...(method==="PATCH"?{payload:{name:"Wrong"}}:{})});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.equal(records,0)} +writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*FIRST"});assert.equal(r.statusCode,409);assert.equal(writes,0);assert.equal(records,0); +writes=records=0;r=await app.inject({method:"PATCH",url:"/api/ipsec/users/1/identity:*FIRST",payload:{name:"Selected"}});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(identities[0]!.comment,"MikrotikManager:ipsec user=Selected");assert.equal(identities[1]!.comment,"MikrotikManager:ipsec user=Shared1"); +writes=records=0;r=await app.inject({method:"DELETE",url:"/api/ipsec/users/1/identity:*FIRST?removeCertificate=false"});assert.equal(r.statusCode,200);assert.equal(writes,1);assert.equal(records,1); } finally { await app.close(); MikrotikClient.fromServer=original } console.log("IPsec HTTP mutations: no-write conflicts, acknowledged partial failure, applied-but-lost ack and remote ID normalization") diff --git a/backend/src/routes/ipsec.ts b/backend/src/routes/ipsec.ts index db71ef96..2de345db 100644 --- a/backend/src/routes/ipsec.ts +++ b/backend/src/routes/ipsec.ts @@ -545,6 +545,13 @@ export function createIpsecRoutes(dependencies = ipsecDefaults): FastifyPluginAs ? (identity["remote-certificate"] ?? "").trim() : String(certRow?.name ?? "").trim() + // Refuse before deleting the identity: a shared certificate must survive. + const deletionCertName = remoteCert || (managed && userName ? clientCertName(userName) : "") + if (removeCertificate && deletionCertName && ( + state.identities.some(i => i[".id"] !== identity?.[".id"] && ((i["remote-certificate"] ?? "").trim() === deletionCertName || (i.certificate ?? "").trim() === deletionCertName)) + || state.peers.some(p => (p.certificate ?? "").trim() === deletionCertName) + )) return reply.status(409).send({ error: "Сертификат используется другой identity или peer. Удалите только выбранную identity, сохранив сертификат" }) + if (identity) { await deleteIdentity(client, identity[".id"]!) if (personal && personal !== sharedMcName) await deleteUserModeConfig(client, personal) diff --git a/backend/src/services/ipsec-live.ts b/backend/src/services/ipsec-live.ts index 864b408d..bdb502f2 100644 --- a/backend/src/services/ipsec-live.ts +++ b/backend/src/services/ipsec-live.ts @@ -272,10 +272,10 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { const findActive = (remoteId: string) => activeByRemote.get(remoteId) ?? activeByRemote.get(remoteId.replace(/^(key-id|fqdn|user-fqdn|address):/, "")) - const identityByRemoteCert = new Map() + const identityByRemoteCert = new Map() for (const i of state.identities) { const rc = (i["remote-certificate"] ?? "").trim() - if (rc) identityByRemoteCert.set(rc, i) + if (rc) identityByRemoteCert.set(rc, [...(identityByRemoteCert.get(rc) ?? []), i]) } const personalStaticIp = (mcNameRaw: string): string | undefined => { @@ -303,44 +303,46 @@ export function mapClients(state: IpsecServerState): IpsecClientDto[] { const referenced = identityByRemoteCert.has(certName) if (!isUser && !referenced && !(caName && signedBy === caName)) continue - const identity = identityByRemoteCert.get(certName) - const comment = identity?.comment ?? "" - const mcName = (identity?.["mode-config"] ?? "").trim() - const cn = String(cert["common-name"] ?? "").trim() - const subject = routerOsCertificateDn(cert) - const bySubject = subject && subjectCounts.get(subject) === 1 ? activeByRemote.get(subject) : undefined - const uniqueCn = cn && cnCounts.get(cn) === 1 - // A literal CN is not a typed remote-id. It may also resemble another - // certificate's complete DN, in which case the bare-CN alias is unsafe. - const byCommonName = uniqueCn && !subjectCounts.has(cn) ? activeByRemote.get(cn) : undefined - const active = bySubject ?? byCommonName - ?? (identity?.["remote-id"] ? findActive(String(identity["remote-id"]).trim()) : undefined) - // Multiple established sessions confirm presence, but no single lease/uptime. - const single = active?.length === 1 ? active[0] : undefined - out.push({ - id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`, - rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`, - serverId: String(server.id), - serverName, - name: identity ? identityDisplayName(identity, state.certs) : (cn || certName), - authMethod: "certificate", - kind: "cert", - certificateName: certName, - certName, - signedBy: signedBy || undefined, - commonName: cn || undefined, - remoteId: (identity?.["remote-id"] ?? "").trim() || undefined, - staticIp: personalStaticIp(mcName), - modeConfigName: mcName || undefined, - peerName: (identity?.peer ?? "").trim() || undefined, - online: Boolean(active), - activeAddress: single?.["dynamic-address"] || single?.["remote-address"] || single?.address || undefined, - activeSince: single?.uptime || single?.established || undefined, - disabled: asBool(identity?.disabled), - comment: comment || undefined, - managed: isUser || isIpsecManagedComment(comment), - }) - claimSession(active) + const identities = identityByRemoteCert.get(certName) ?? [undefined] + for (const identity of identities) { + const comment = identity?.comment ?? "" + const mcName = (identity?.["mode-config"] ?? "").trim() + const cn = String(cert["common-name"] ?? "").trim() + const subject = routerOsCertificateDn(cert) + const bySubject = subject && subjectCounts.get(subject) === 1 ? activeByRemote.get(subject) : undefined + const uniqueCn = cn && cnCounts.get(cn) === 1 + // A literal CN is not a typed remote-id. It may also resemble another + // certificate's complete DN, in which case the bare-CN alias is unsafe. + const byCommonName = uniqueCn && !subjectCounts.has(cn) ? activeByRemote.get(cn) : undefined + const active = bySubject ?? byCommonName + ?? (identity?.["remote-id"] ? findActive(String(identity["remote-id"]).trim()) : undefined) + // Multiple established sessions confirm presence, but no single lease/uptime. + const single = active?.length === 1 ? active[0] : undefined + out.push({ + id: `${server.id}:${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`, + rosId: `${identity ? "identity" : "cert"}:${String(identity?.[".id"] ?? cert[".id"] ?? certName)}`, + serverId: String(server.id), + serverName, + name: identity ? identityDisplayName(identity, state.certs) : (cn || certName), + authMethod: "certificate", + kind: "cert", + certificateName: certName, + certName, + signedBy: signedBy || undefined, + commonName: cn || undefined, + remoteId: (identity?.["remote-id"] ?? "").trim() || undefined, + staticIp: personalStaticIp(mcName), + modeConfigName: mcName || undefined, + peerName: (identity?.peer ?? "").trim() || undefined, + online: Boolean(active), + activeAddress: single?.["dynamic-address"] || single?.["remote-address"] || single?.address || undefined, + activeSince: single?.uptime || single?.established || undefined, + disabled: asBool(identity?.disabled), + comment: comment || undefined, + managed: isUser || isIpsecManagedComment(comment), + }) + claimSession(active) + } } // 2. PSK-identity (managed или IKEv2 remote-access). @@ -397,7 +399,7 @@ export function resolveIpsecClientTarget(state: IpsecServerState, token: string) const clients = mapClients(state) const identity = kind === "cert" ? undefined : state.identities.find(i => i[".id"] === raw && clients.some(c => c.rosId === "identity:" + raw)) const certRow = kind === "identity" ? undefined : state.certs.find(c => (c[".id"] === raw || c.name === raw) && clients.some(user => user.kind === "cert" && user.certName === c.name)) - return { identity, certRow, ambiguous: Boolean(!kind && identity && certRow) } + return { identity, certRow, ambiguous: Boolean((!kind && identity && certRow) || (certRow && state.identities.filter(i => (i["remote-certificate"] ?? "").trim() === certRow.name).length > 1)) } } export function mapServerSummary(state: IpsecServerState, clients: IpsecClientDto[]): IpsecServerSummaryDto { diff --git a/backend/src/services/ipsec-state.test.ts b/backend/src/services/ipsec-state.test.ts index 8f67afd1..958534e0 100644 --- a/backend/src/services/ipsec-state.test.ts +++ b/backend/src/services/ipsec-state.test.ts @@ -197,4 +197,20 @@ aliasesState.active[1]!.state = "dying" assert.equal(mapClients(aliasesState)[0]?.activeAddress, aliasesState.active[0]!["dynamic-address"]); checks++ aliasesState.active[0]!.state = "starting" assert.equal(mapClients(aliasesState)[0]?.online, false); checks++ +const sharedCertState = { ...namespaceState, certs: [namespaceState.certs[0]!], identities: [0,1].map(n => ({ ".id": "*SHARED" + n, peer: "ike", "auth-method": "digital-signature", "remote-certificate": "ipsec-user-plain", "mode-config": "mc" + n, comment: ipsecManagedComment("user=Client" + n) })), modeConfigs: [0,1].map(n => ({ ".id": "*MC" + n, name: "mc" + n, address: "198.18.114." + (250+n) })), active: [{ ".id": "*SHAREDSESSION", id: "CN=alice", state: "established", "dynamic-address": "198.18.114.250" }] } as typeof state +for (const reverse of [false,true]) { + if (reverse) sharedCertState.identities.reverse() + const rows = mapClients(sharedCertState) + assert.equal(rows.length,2) + assert(rows.every(c => c.sessionAmbiguous && !c.online && !c.activeAddress)) + assert.equal(rows.find(c => c.rosId === "identity:*SHARED0")?.staticIp,"198.18.114.250") + assert.equal(rows.find(c => c.rosId === "identity:*SHARED1")?.staticIp,"198.18.114.251"); checks++ +} +assert(resolveIpsecClientTarget(sharedCertState,"cert:*PLAIN").ambiguous); checks++ +for(const id of ["*SHARED0","*SHARED1"]) {assert.equal(resolveIpsecClientTarget(sharedCertState,"identity:"+id).identity?.[".id"],id);checks++} +sharedCertState.identities.forEach((i,n) => { i["remote-id"] = "fqdn:client"+n+".example" }) +sharedCertState.active[0]!.id="client0.example" +const distinctRows=mapClients(sharedCertState) +assert.equal(distinctRows.filter(c => c.online).length,1) +assert(distinctRows.every(c => !c.sessionAmbiguous)); checks++ console.log(`ipsec-state: ${checks} checks passed`) diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index a65b8c66..8431c858 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-10: завершён12.5.113 — несколько established сессий с одним ID больше не дают ложный офлайн; один клиент online без произвольного адреса/uptime, общие claims разных клиентов остаются «Не определено». State108/108, 8 профильных скриптов, backend build/ESLint, E2E16/16. Backend ux-sessions113/version1.0.0-test.sessions113, frontend ux-alias112-version/version1.0.0-test.alias112. Два live reader/API и два browser smoke passed; множественные сессии проверены контролируемым снимком. Следующий114 — один сертификат с несколькими identity и безопасность выбора объекта для операций.12.5 открыт. +Актуальное продолжение 2026-10-10: завершён12.5.114 — один certificate больше не скрывает identity: отдельные IDs/имена/mode-config/static IP, общая сессия «Не определено». Certificate target с несколькими refs и удаление используемого сертификата дают409 до записи. State114/114, 8 профильных скриптов/build/ESLint, E2E18/18, live UI4/4. Backend ux-refs114/version1.0.0-test.refs114, frontend ux-alias112-version/version1.0.0-test.alias112. Native matching conflict записан; PATCH200 после удаления собственного дубля, cleanup13×2 passed. Следующий115 — понятное удаление только identity с сохранением общего сертификата в UI;12.5 открыт. 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). @@ -1132,3 +1132,24 @@ Red-before state test остановился на ложном online; посл | --- | --- | | [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | ID → группа established → один клиент «онлайн» / разные клиенты «Не определено» | | [ReUI Stats12](https://reui.io/preview/base/stats-12?ref=mcp) | Несколько сессий → один подтверждённый клиент → сводка без двойного счёта | + +## 12.5.114 — IPsec: общий сертификат, разные identity и безопасный target (2026-10-10) + +Устранён last-reference-wins: mapper хранит все ссылки на remote-certificate и выдаёт отдельную строку для каждой identity. У каждой строки свой identity:, имя, peer, Remote ID, mode-config/static IP. Сертификат без ссылок сохраняет cert:. Общая observed сессия не присваивается двум строкам: sessionAmbiguous и «Не определено»; независимые explicit ID проверены отдельно. Перестановка native identity rows не меняет состав/поля каталога. + +Resolver помечает запрос по certificate target как неоднозначный при нескольких refs, поэтому PATCH/DELETE не выбирают первую identity. DELETE identity с removeCertificate=true проверяет другие identity/peer refs до первого удаления и возвращает409. Явный removeCertificate=false допускает удаление выбранной identity с сохранением сертификата; локальный HTTP тест подтверждает один delete/историю и отсутствие других writes. Интерфейс пока не предлагает этот отдельный вариант — следующий115 должен сделать его понятным. Прямой endpoint принудительного удаления сертификатов не менялся. + +Изменены backend services/ipsec-live.ts, routes/ipsec.ts, state/mutation tests и UI HTTP fixture. Контракт и компоненты ReUI/base-nova не менялись; существующая таблица принимает отдельные DTO rows. State114/114, все8 профильных скриптов, backend build и scoped ESLint passed. E2E18 unique/35.9s: две новые проверки общего сертификата1280/320 плюс профильная регрессия. + +Before на backend113: native established с адресом198.18.114.250, две certificate identities, но каталог сохранил только вторую и присвоил ей адрес; первая отсутствовала. After на ux-refs114 обе строки присутствуют и unknown без активного адреса, конфигурационные250/251 сохраняются. Certificate PATCH/DELETE и identity DELETE с общим сертификатом вернули409. PATCH выбранной identity при одинаковых match-by=certificate условиях вернул502 с native400 «a matching identity already exists»; обе identity остались неизменными. Это ограничение RouterOS, успешное редактирование одновременно существующих идентичных match rules НЕ принято. После удаления собственного дубля исходная identity восстановила online/250; её API PATCH имени и возврат исходного имени дали200, native readback совпал. Два успешных PATCH вызывают запись истории с dedupe; число вставленных revisions отдельно не заявляется. + +Live browser4/4 без HTTP mocks: dark/light1280/320, обе реальные строки, unknown alert/сводка, decoy offline, reload, overflow0, pageerrors0/APIwrites0. Desktop light и mobile dark снимки просмотрены. Первичные helper ошибки сохранены: syntax до вызовов; DELETE с Content-Type без тела дал400 до guard; затем native matching conflict потребовал правильного критерия приёмки. Каждый запуск с сетевыми объектами завершил cleanup13 коллекций×2; предварительные отказы не выданы за passed. + +Backend только: ux-refs114/version1.0.0-test.refs114, base60b2e09/context build-sessions113 плюс два overrides; source и compiled bytehashes совпали. Frontend ux-alias112-version/version1.0.0-test.alias112 сохранён. Backend/PG healthy; frontend подтверждён браузером, собственного healthcheck нет. Rollback .env pre-refs114.env сохранён privately, старый образ оставлен. Собственные certificates/keys/imports/peers/identities/mode-config/profile/proposal/groups/templates/files удалены; исходные13 коллекций×2 совпали, default/public/metadata/195.195.195.195/32/logging сохранены. Оба CHR доступны; пароли прежние. + +Протокол: [shared certificate](ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json), [live UI](ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json). Следующий115 — UI для удаления только выбранной identity с сохранением общего сертификата; затем согласовать оставшиеся native/внешние ограничения и итоговую регрессию. Множественные реальные сессии одного ID, клиентские ОС, полнаяPKI, интеграции и multi-WAN не закрыты этим блоком. + +| Референс | Схема | +| --- | --- | +| [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Сертификат → все identity → отдельные строки/настройки → выбор по identity ID | +| [ReUI Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp) | Выбранная identity → проверка ссылок → API409 без записи либо подтверждённая операция | diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5b598382..51e10994 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,12 +1,12 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-10: завершён12.5.113 — несколько established сессий с одним ID больше не дают ложный офлайн; один клиент online без произвольного адреса/uptime, общие claims разных клиентов остаются «Не определено». State108/108, 8 профильных скриптов, backend build/ESLint, E2E16/16. Backend ux-sessions113/version1.0.0-test.sessions113, frontend ux-alias112-version/version1.0.0-test.alias112. Два live reader/API и два browser smoke passed; множественные сессии проверены контролируемым снимком. Следующий114 — один сертификат с несколькими identity и безопасность выбора объекта для операций.12.5 открыт. +Актуальное продолжение 2026-10-10: завершён12.5.114 — один certificate больше не скрывает identity: отдельные IDs/имена/mode-config/static IP, общая сессия «Не определено». Certificate target с несколькими refs и удаление используемого сертификата дают409 до записи. State114/114, 8 профильных скриптов/build/ESLint, E2E18/18, live UI4/4. Backend ux-refs114/version1.0.0-test.refs114, frontend ux-alias112-version/version1.0.0-test.alias112. Native matching conflict записан; PATCH200 после удаления собственного дубля, cleanup13×2 passed. Следующий115 — понятное удаление только identity с сохранением общего сертификата в UI;12.5 открыт. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-10. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. ## Актуальная точка продолжения — 2026-10-10 -Актуальное продолжение 2026-10-10: завершён12.5.113 — несколько established сессий с одним ID больше не дают ложный офлайн; один клиент online без произвольного адреса/uptime, общие claims разных клиентов остаются «Не определено». State108/108, 8 профильных скриптов, backend build/ESLint, E2E16/16. Backend ux-sessions113/version1.0.0-test.sessions113, frontend ux-alias112-version/version1.0.0-test.alias112. Два live reader/API и два browser smoke passed; множественные сессии проверены контролируемым снимком. Следующий114 — один сертификат с несколькими identity и безопасность выбора объекта для операций.12.5 открыт. +Актуальное продолжение 2026-10-10: завершён12.5.114 — один certificate больше не скрывает identity: отдельные IDs/имена/mode-config/static IP, общая сессия «Не определено». Certificate target с несколькими refs и удаление используемого сертификата дают409 до записи. State114/114, 8 профильных скриптов/build/ESLint, E2E18/18, live UI4/4. Backend ux-refs114/version1.0.0-test.refs114, frontend ux-alias112-version/version1.0.0-test.alias112. Native matching conflict записан; PATCH200 после удаления собственного дубля, cleanup13×2 passed. Следующий115 — понятное удаление только identity с сохранением общего сертификата в UI;12.5 открыт. Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index ae3850da..f6089baa 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-10: завершён12.5.113 — несколько established сессий с одним ID больше не дают ложный офлайн; один клиент online без произвольного адреса/uptime, общие claims разных клиентов остаются «Не определено». State108/108, 8 профильных скриптов, backend build/ESLint, E2E16/16. Backend ux-sessions113/version1.0.0-test.sessions113, frontend ux-alias112-version/version1.0.0-test.alias112. Два live reader/API и два browser smoke passed; множественные сессии проверены контролируемым снимком. Следующий114 — один сертификат с несколькими identity и безопасность выбора объекта для операций.12.5 открыт. +Актуальное продолжение 2026-10-10: завершён12.5.114 — один certificate больше не скрывает identity: отдельные IDs/имена/mode-config/static IP, общая сессия «Не определено». Certificate target с несколькими refs и удаление используемого сертификата дают409 до записи. State114/114, 8 профильных скриптов/build/ESLint, E2E18/18, live UI4/4. Backend ux-refs114/version1.0.0-test.refs114, frontend ux-alias112-version/version1.0.0-test.alias112. Native matching conflict записан; PATCH200 после удаления собственного дубля, cleanup13×2 passed. Следующий115 — понятное удаление только identity с сохранением общего сертификата в UI;12.5 открыт. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -36,7 +36,28 @@ | 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–113 и ограничения ниже. Runtime backend ux-sessions113/frontend ux-alias112-version. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–114 и ограничения ниже. Runtime backend ux-refs114/frontend ux-alias112-version. | + +## 12.5.114 — IPsec: общий сертификат, разные identity и безопасный target (2026-10-10) + +Устранён last-reference-wins: mapper хранит все ссылки на remote-certificate и выдаёт отдельную строку для каждой identity. У каждой строки свой identity:, имя, peer, Remote ID, mode-config/static IP. Сертификат без ссылок сохраняет cert:. Общая observed сессия не присваивается двум строкам: sessionAmbiguous и «Не определено»; независимые explicit ID проверены отдельно. Перестановка native identity rows не меняет состав/поля каталога. + +Resolver помечает запрос по certificate target как неоднозначный при нескольких refs, поэтому PATCH/DELETE не выбирают первую identity. DELETE identity с removeCertificate=true проверяет другие identity/peer refs до первого удаления и возвращает409. Явный removeCertificate=false допускает удаление выбранной identity с сохранением сертификата; локальный HTTP тест подтверждает один delete/историю и отсутствие других writes. Интерфейс пока не предлагает этот отдельный вариант — следующий115 должен сделать его понятным. Прямой endpoint принудительного удаления сертификатов не менялся. + +Изменены backend services/ipsec-live.ts, routes/ipsec.ts, state/mutation tests и UI HTTP fixture. Контракт и компоненты ReUI/base-nova не менялись; существующая таблица принимает отдельные DTO rows. State114/114, все8 профильных скриптов, backend build и scoped ESLint passed. E2E18 unique/35.9s: две новые проверки общего сертификата1280/320 плюс профильная регрессия. + +Before на backend113: native established с адресом198.18.114.250, две certificate identities, но каталог сохранил только вторую и присвоил ей адрес; первая отсутствовала. After на ux-refs114 обе строки присутствуют и unknown без активного адреса, конфигурационные250/251 сохраняются. Certificate PATCH/DELETE и identity DELETE с общим сертификатом вернули409. PATCH выбранной identity при одинаковых match-by=certificate условиях вернул502 с native400 «a matching identity already exists»; обе identity остались неизменными. Это ограничение RouterOS, успешное редактирование одновременно существующих идентичных match rules НЕ принято. После удаления собственного дубля исходная identity восстановила online/250; её API PATCH имени и возврат исходного имени дали200, native readback совпал. Два успешных PATCH вызывают запись истории с dedupe; число вставленных revisions отдельно не заявляется. + +Live browser4/4 без HTTP mocks: dark/light1280/320, обе реальные строки, unknown alert/сводка, decoy offline, reload, overflow0, pageerrors0/APIwrites0. Desktop light и mobile dark снимки просмотрены. Первичные helper ошибки сохранены: syntax до вызовов; DELETE с Content-Type без тела дал400 до guard; затем native matching conflict потребовал правильного критерия приёмки. Каждый запуск с сетевыми объектами завершил cleanup13 коллекций×2; предварительные отказы не выданы за passed. + +Backend только: ux-refs114/version1.0.0-test.refs114, base60b2e09/context build-sessions113 плюс два overrides; source и compiled bytehashes совпали. Frontend ux-alias112-version/version1.0.0-test.alias112 сохранён. Backend/PG healthy; frontend подтверждён браузером, собственного healthcheck нет. Rollback .env pre-refs114.env сохранён privately, старый образ оставлен. Собственные certificates/keys/imports/peers/identities/mode-config/profile/proposal/groups/templates/files удалены; исходные13 коллекций×2 совпали, default/public/metadata/195.195.195.195/32/logging сохранены. Оба CHR доступны; пароли прежние. + +Протокол: [shared certificate](ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json), [live UI](ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json). Следующий115 — UI для удаления только выбранной identity с сохранением общего сертификата; затем согласовать оставшиеся native/внешние ограничения и итоговую регрессию. Множественные реальные сессии одного ID, клиентские ОС, полнаяPKI, интеграции и multi-WAN не закрыты этим блоком. + +| Референс | Схема | +| --- | --- | +| [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Сертификат → все identity → отдельные строки/настройки → выбор по identity ID | +| [ReUI Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp) | Выбранная identity → проверка ссылок → API409 без записи либо подтверждённая операция | ## 12.5.113 — IPsec: несколько сессий одного клиента без ложного офлайн (2026-10-10) diff --git a/docs/ux-ui-remaining-acceptance.md b/docs/ux-ui-remaining-acceptance.md index 7a87e29e..c5fbdc7b 100644 --- a/docs/ux-ui-remaining-acceptance.md +++ b/docs/ux-ui-remaining-acceptance.md @@ -1,8 +1,8 @@ # Оставшаяся внешняя приёмка — 2026-10-10 -Это актуальная матрица после 12.5.113. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. +Это актуальная матрица после 12.5.114. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. -Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-sessions113`, frontend `ux-alias112-version`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. +Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-refs114`, frontend `ux-alias112-version`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. | Раздел | Подтверждено | Осталось / условие завершения | | --- | --- | --- | @@ -12,7 +12,7 @@ | GRE | Native lifecycle, metadata, live формы/меню/URL/Ping53–57; GRE используется в OSPF/BGP/IPsec последующих блоков; native IPv6 ULA inner109 | IPv6 outer и application IPv6 interface address lifecycle, независимые WAN, масштаб и длительное восстановление; принятие GRE не означает приёмку всех сценариев автоматического расписания | | WireGuard | Native handshake/data plane58, deletion60, live create/peer/disable/enable/delete62, mobile64, export/import67–74; native IPv6 ULA inner/allowedIps/handshake109 | Клиентские ОС и произвольные конфигурации, IPv6 outer/scale и application IPv6 interface address lifecycle, физический lost ACK и устойчивое восстановление; `.conf` с placeholders — шаблон, не принятый клиентский tunnel | | VXLAN | Собственная пара/private Ping/catalog/URL/export75–76; bridge/FDB/port loss/recovery107; IPv6 ULA inner по IPv4 outer108 | API приложения read-only: create/delete проверены native. IPv6 outer, масштаб/производительность, профильные mutation RBAC/concurrency отдельно не приняты | -| IPsec | PSK и сертификатное шифрование77–82, active peer/online/mode-config, scoped NAT/group, partial/unknown/applied receipts, restore conflicts, JWT; full DN/специальные символы/online110; literal CN namespace guards и explicit FQDN111; certificate/PSK shared ID unknown/recovery112; multiple sessions controlled snapshots113 | Реальные одновременные sessions с одним ID, один сертификат с несколькими identity refs/target guards; клиентские Windows/macOS/iOS/Android, прочие DN форматы/custom OIDs/IKE ID namespaces, полная PKI и policy/profile/proposal restore, физический disconnect, durable retry/multi-instance, IPv6/maximum throughput | +| IPsec | PSK и сертификатное шифрование77–82, active peer/online/mode-config, scoped NAT/group, partial/unknown/applied receipts, restore conflicts, JWT; full DN/специальные символы/online110; literal CN namespace guards и explicit FQDN111; certificate/PSK shared ID unknown/recovery112; multiple sessions controlled snapshots113; shared certificate rows/target guards114 | Реальные одновременные sessions с одним ID, UI удаления identity с сохранением общего сертификата; native edit identical matching rules rejected; клиентские Windows/macOS/iOS/Android, прочие DN форматы/custom OIDs/IKE ID namespaces, полная PKI и policy/profile/proposal restore, физический disconnect, durable retry/multi-instance, IPv6/maximum throughput | | OSPF/BFD | Real Full/own routes84–85, раздельные наблюдения BFD86, effective disabled/optimizer cost/partial/RBAC87, alternate path90 и CIDR91 | Sustained BFD-only gate не принят: в86 сосед мог вновь статьFull при доступныхHello. В90 переходный Ping18/40 с55% loss: бесшовность не принята. REST timing — наблюдаемая граница, не SLA; IPv6/scale/independent WAN открыты | | BGP | Established83, exact /32 exchange/API/UI/administrative withdrawal92, BFD primary GRE loss/alternate/recovery93 | Hold timer/TCP loss безBFD принят104; packet loss в переходе, разные внешние peers/WAN policy, IPv6/scale. Два логических пути используют тот же public WAN; physical redundancy не принята | | Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята | @@ -25,7 +25,7 @@ ## Следующая последовательность 1. **12.5.95 завершён:** [source/context/compiled audit](ux-ui/acceptance/deployment-source-audit-2026-10-10.json), production source drift не выявлен; нормализация и исключения явно указаны. -2. **12.5.96 timezone завершён:** [6 live UI cases](ux-ui/acceptance/browser-speed-timezone-2026-10-10.json). 97 HTTP receipt disconnect принят: [протокол](ux-ui/acceptance/speed-disconnect-2026-10-10.json). 98 сообщение неизвестного native результата исправлено/развёрнуто. 99 main/context завершён с точной записью одного failure/delta;100 transport и101 accessibility исправлены/развёрнуты.102 malformed PATCH исправлен/принят.103 packet rows/public/GRE приняты в границах коротких samples; initial loss/root cause остаётся открытым.104 hold/TCP loss/recovery принят.105 source/runtime/evidence сверка passed.106 read-only readiness: auth disabled/Portal localhost/bot and chat unconfigured.107 — native bridge/FDB/loss/recovery принят.108 — IPv6 inner VXLAN принят, initial0/3 и1/3 остаются в протоколе;109 — native GRE/WireGuard IPv6 inner принят; readers /ip/address не показывают native IPv6 interface addresses.110 — полный native DN/online исправлен и принят;111 — literal CN guards/real explicit FQDN accepted;112 — certificate/PSK alias ambiguity marked unknown/recovery accepted;113 — repeated native ID groups исправлены на controlled snapshots; следующий114 — multiple identity refs/target guards. Реальные интеграции требуют тестовых доступов и отдельного разрешения на сообщение. +2. **12.5.96 timezone завершён:** [6 live UI cases](ux-ui/acceptance/browser-speed-timezone-2026-10-10.json). 97 HTTP receipt disconnect принят: [протокол](ux-ui/acceptance/speed-disconnect-2026-10-10.json). 98 сообщение неизвестного native результата исправлено/развёрнуто. 99 main/context завершён с точной записью одного failure/delta;100 transport и101 accessibility исправлены/развёрнуты.102 malformed PATCH исправлен/принят.103 packet rows/public/GRE приняты в границах коротких samples; initial loss/root cause остаётся открытым.104 hold/TCP loss/recovery принят.105 source/runtime/evidence сверка passed.106 read-only readiness: auth disabled/Portal localhost/bot and chat unconfigured.107 — native bridge/FDB/loss/recovery принят.108 — IPv6 inner VXLAN принят, initial0/3 и1/3 остаются в протоколе;109 — native GRE/WireGuard IPv6 inner принят; readers /ip/address не показывают native IPv6 interface addresses.110 — полный native DN/online исправлен и принят;111 — literal CN guards/real explicit FQDN accepted;112 — certificate/PSK alias ambiguity marked unknown/recovery accepted;113 — repeated native ID groups исправлены на controlled snapshots; 114 — multiple refs/target guards/native conflict приняты в описанных границах. Следующий115 — UI сохранения общего сертификата при удалении identity. Реальные интеграции требуют тестовых доступов и отдельного разрешения на сообщение. 3. **Заключительный профильный контроль:** после последнего исправления собрать только затронутую часть, проверить её live и обновить матрицу. Общую регрессию выполнить один раз на согласованном runtime. 4. **Внешние зависимости:** Telegram/Portal/client OS/multi-WAN/scale проходят отдельную приёмку при наличии нужных аккаунтов, устройств и разрешений. Лабораторный результат двух CHR не закрывает их автоматически. @@ -47,3 +47,24 @@ | --- | --- | | [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | ID → группа established → один клиент «онлайн» / разные клиенты «Не определено» | | [ReUI Stats12](https://reui.io/preview/base/stats-12?ref=mcp) | Несколько сессий → один подтверждённый клиент → сводка без двойного счёта | + +## 12.5.114 — IPsec: общий сертификат, разные identity и безопасный target (2026-10-10) + +Устранён last-reference-wins: mapper хранит все ссылки на remote-certificate и выдаёт отдельную строку для каждой identity. У каждой строки свой identity:, имя, peer, Remote ID, mode-config/static IP. Сертификат без ссылок сохраняет cert:. Общая observed сессия не присваивается двум строкам: sessionAmbiguous и «Не определено»; независимые explicit ID проверены отдельно. Перестановка native identity rows не меняет состав/поля каталога. + +Resolver помечает запрос по certificate target как неоднозначный при нескольких refs, поэтому PATCH/DELETE не выбирают первую identity. DELETE identity с removeCertificate=true проверяет другие identity/peer refs до первого удаления и возвращает409. Явный removeCertificate=false допускает удаление выбранной identity с сохранением сертификата; локальный HTTP тест подтверждает один delete/историю и отсутствие других writes. Интерфейс пока не предлагает этот отдельный вариант — следующий115 должен сделать его понятным. Прямой endpoint принудительного удаления сертификатов не менялся. + +Изменены backend services/ipsec-live.ts, routes/ipsec.ts, state/mutation tests и UI HTTP fixture. Контракт и компоненты ReUI/base-nova не менялись; существующая таблица принимает отдельные DTO rows. State114/114, все8 профильных скриптов, backend build и scoped ESLint passed. E2E18 unique/35.9s: две новые проверки общего сертификата1280/320 плюс профильная регрессия. + +Before на backend113: native established с адресом198.18.114.250, две certificate identities, но каталог сохранил только вторую и присвоил ей адрес; первая отсутствовала. After на ux-refs114 обе строки присутствуют и unknown без активного адреса, конфигурационные250/251 сохраняются. Certificate PATCH/DELETE и identity DELETE с общим сертификатом вернули409. PATCH выбранной identity при одинаковых match-by=certificate условиях вернул502 с native400 «a matching identity already exists»; обе identity остались неизменными. Это ограничение RouterOS, успешное редактирование одновременно существующих идентичных match rules НЕ принято. После удаления собственного дубля исходная identity восстановила online/250; её API PATCH имени и возврат исходного имени дали200, native readback совпал. Два успешных PATCH вызывают запись истории с dedupe; число вставленных revisions отдельно не заявляется. + +Live browser4/4 без HTTP mocks: dark/light1280/320, обе реальные строки, unknown alert/сводка, decoy offline, reload, overflow0, pageerrors0/APIwrites0. Desktop light и mobile dark снимки просмотрены. Первичные helper ошибки сохранены: syntax до вызовов; DELETE с Content-Type без тела дал400 до guard; затем native matching conflict потребовал правильного критерия приёмки. Каждый запуск с сетевыми объектами завершил cleanup13 коллекций×2; предварительные отказы не выданы за passed. + +Backend только: ux-refs114/version1.0.0-test.refs114, base60b2e09/context build-sessions113 плюс два overrides; source и compiled bytehashes совпали. Frontend ux-alias112-version/version1.0.0-test.alias112 сохранён. Backend/PG healthy; frontend подтверждён браузером, собственного healthcheck нет. Rollback .env pre-refs114.env сохранён privately, старый образ оставлен. Собственные certificates/keys/imports/peers/identities/mode-config/profile/proposal/groups/templates/files удалены; исходные13 коллекций×2 совпали, default/public/metadata/195.195.195.195/32/logging сохранены. Оба CHR доступны; пароли прежние. + +Протокол: [shared certificate](ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json), [live UI](ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json). Следующий115 — UI для удаления только выбранной identity с сохранением общего сертификата; затем согласовать оставшиеся native/внешние ограничения и итоговую регрессию. Множественные реальные сессии одного ID, клиентские ОС, полнаяPKI, интеграции и multi-WAN не закрыты этим блоком. + +| Референс | Схема | +| --- | --- | +| [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Сертификат → все identity → отдельные строки/настройки → выбор по identity ID | +| [ReUI Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp) | Выбранная identity → проверка ссылок → API409 без записи либо подтверждённая операция | diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 87603388..dd756cd5 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-10: завершён12.5.113 — несколько established сессий с одним ID больше не дают ложный офлайн; один клиент online без произвольного адреса/uptime, общие claims разных клиентов остаются «Не определено». State108/108, 8 профильных скриптов, backend build/ESLint, E2E16/16. Backend ux-sessions113/version1.0.0-test.sessions113, frontend ux-alias112-version/version1.0.0-test.alias112. Два live reader/API и два browser smoke passed; множественные сессии проверены контролируемым снимком. Следующий114 — один сертификат с несколькими identity и безопасность выбора объекта для операций.12.5 открыт. +Актуальное продолжение 2026-10-10: завершён12.5.114 — один certificate больше не скрывает identity: отдельные IDs/имена/mode-config/static IP, общая сессия «Не определено». Certificate target с несколькими refs и удаление используемого сертификата дают409 до записи. State114/114, 8 профильных скриптов/build/ESLint, E2E18/18, live UI4/4. Backend ux-refs114/version1.0.0-test.refs114, frontend ux-alias112-version/version1.0.0-test.alias112. Native matching conflict записан; PATCH200 после удаления собственного дубля, cleanup13×2 passed. Следующий115 — понятное удаление только identity с сохранением общего сертификата в UI;12.5 открыт. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -2437,3 +2437,24 @@ Red-before state test остановился на ложном online; посл | --- | --- | | [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | ID → группа established → один клиент «онлайн» / разные клиенты «Не определено» | | [ReUI Stats12](https://reui.io/preview/base/stats-12?ref=mcp) | Несколько сессий → один подтверждённый клиент → сводка без двойного счёта | + +## 12.5.114 — IPsec: общий сертификат, разные identity и безопасный target (2026-10-10) + +Устранён last-reference-wins: mapper хранит все ссылки на remote-certificate и выдаёт отдельную строку для каждой identity. У каждой строки свой identity:, имя, peer, Remote ID, mode-config/static IP. Сертификат без ссылок сохраняет cert:. Общая observed сессия не присваивается двум строкам: sessionAmbiguous и «Не определено»; независимые explicit ID проверены отдельно. Перестановка native identity rows не меняет состав/поля каталога. + +Resolver помечает запрос по certificate target как неоднозначный при нескольких refs, поэтому PATCH/DELETE не выбирают первую identity. DELETE identity с removeCertificate=true проверяет другие identity/peer refs до первого удаления и возвращает409. Явный removeCertificate=false допускает удаление выбранной identity с сохранением сертификата; локальный HTTP тест подтверждает один delete/историю и отсутствие других writes. Интерфейс пока не предлагает этот отдельный вариант — следующий115 должен сделать его понятным. Прямой endpoint принудительного удаления сертификатов не менялся. + +Изменены backend services/ipsec-live.ts, routes/ipsec.ts, state/mutation tests и UI HTTP fixture. Контракт и компоненты ReUI/base-nova не менялись; существующая таблица принимает отдельные DTO rows. State114/114, все8 профильных скриптов, backend build и scoped ESLint passed. E2E18 unique/35.9s: две новые проверки общего сертификата1280/320 плюс профильная регрессия. + +Before на backend113: native established с адресом198.18.114.250, две certificate identities, но каталог сохранил только вторую и присвоил ей адрес; первая отсутствовала. After на ux-refs114 обе строки присутствуют и unknown без активного адреса, конфигурационные250/251 сохраняются. Certificate PATCH/DELETE и identity DELETE с общим сертификатом вернули409. PATCH выбранной identity при одинаковых match-by=certificate условиях вернул502 с native400 «a matching identity already exists»; обе identity остались неизменными. Это ограничение RouterOS, успешное редактирование одновременно существующих идентичных match rules НЕ принято. После удаления собственного дубля исходная identity восстановила online/250; её API PATCH имени и возврат исходного имени дали200, native readback совпал. Два успешных PATCH вызывают запись истории с dedupe; число вставленных revisions отдельно не заявляется. + +Live browser4/4 без HTTP mocks: dark/light1280/320, обе реальные строки, unknown alert/сводка, decoy offline, reload, overflow0, pageerrors0/APIwrites0. Desktop light и mobile dark снимки просмотрены. Первичные helper ошибки сохранены: syntax до вызовов; DELETE с Content-Type без тела дал400 до guard; затем native matching conflict потребовал правильного критерия приёмки. Каждый запуск с сетевыми объектами завершил cleanup13 коллекций×2; предварительные отказы не выданы за passed. + +Backend только: ux-refs114/version1.0.0-test.refs114, base60b2e09/context build-sessions113 плюс два overrides; source и compiled bytehashes совпали. Frontend ux-alias112-version/version1.0.0-test.alias112 сохранён. Backend/PG healthy; frontend подтверждён браузером, собственного healthcheck нет. Rollback .env pre-refs114.env сохранён privately, старый образ оставлен. Собственные certificates/keys/imports/peers/identities/mode-config/profile/proposal/groups/templates/files удалены; исходные13 коллекций×2 совпали, default/public/metadata/195.195.195.195/32/logging сохранены. Оба CHR доступны; пароли прежние. + +Протокол: [shared certificate](ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json), [live UI](ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json). Следующий115 — UI для удаления только выбранной identity с сохранением общего сертификата; затем согласовать оставшиеся native/внешние ограничения и итоговую регрессию. Множественные реальные сессии одного ID, клиентские ОС, полнаяPKI, интеграции и multi-WAN не закрыты этим блоком. + +| Референс | Схема | +| --- | --- | +| [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Сертификат → все identity → отдельные строки/настройки → выбор по identity ID | +| [ReUI Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp) | Выбранная identity → проверка ссылок → API409 без записи либо подтверждённая операция | diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 5637812c..303c7aa7 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -1,6 +1,6 @@ # Тестовый сервер UX/UI — 2026-10-10 -Сервер161.104.46.222: backend ux-sessions113/version1.0.0-test.sessions113, frontend ux-alias112-version/version1.0.0-test.alias112. Backend/PG healthy, live API и browser smoke1280/320 passed. Оба CHR доступны;113 не менял RouterOS/пароли и не создавал объектов. Множественные сессии проверены контролируемым snapshot, не новыми одновременными туннелями. Следующий114 — несколько identity refs одного сертификата. +Сервер161.104.46.222: backend ux-refs114/version1.0.0-test.refs114, frontend ux-alias112-version/version1.0.0-test.alias112. Backend/PG healthy; native shared-certificate guard и UI4/4 passed. RouterOS matching conflict зафиксирован, PATCH200 после удаления собственного дубля. Cleanup13×2 passed; оба CHR доступны, пароли прежние. Следующий115 — UI удаления identity с сохранением сертификата. | Параметр | Проверенное состояние | | --- | --- | @@ -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-sessions113 и mmapp-test/frontend:ux-alias112-version; запущены | +| Образы приложения | mmapp-test/backend:ux-refs114 и mmapp-test/frontend:ux-alias112-version; запущены | Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example). @@ -863,3 +863,24 @@ Runtime backend ux-patch102/version1.0.0-test.patch102/image34fca84b8721…; fro | --- | --- | | [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | ID → группа established → один клиент «онлайн» / разные клиенты «Не определено» | | [ReUI Stats12](https://reui.io/preview/base/stats-12?ref=mcp) | Несколько сессий → один подтверждённый клиент → сводка без двойного счёта | + +## 12.5.114 — IPsec: общий сертификат, разные identity и безопасный target (2026-10-10) + +Устранён last-reference-wins: mapper хранит все ссылки на remote-certificate и выдаёт отдельную строку для каждой identity. У каждой строки свой identity:, имя, peer, Remote ID, mode-config/static IP. Сертификат без ссылок сохраняет cert:. Общая observed сессия не присваивается двум строкам: sessionAmbiguous и «Не определено»; независимые explicit ID проверены отдельно. Перестановка native identity rows не меняет состав/поля каталога. + +Resolver помечает запрос по certificate target как неоднозначный при нескольких refs, поэтому PATCH/DELETE не выбирают первую identity. DELETE identity с removeCertificate=true проверяет другие identity/peer refs до первого удаления и возвращает409. Явный removeCertificate=false допускает удаление выбранной identity с сохранением сертификата; локальный HTTP тест подтверждает один delete/историю и отсутствие других writes. Интерфейс пока не предлагает этот отдельный вариант — следующий115 должен сделать его понятным. Прямой endpoint принудительного удаления сертификатов не менялся. + +Изменены backend services/ipsec-live.ts, routes/ipsec.ts, state/mutation tests и UI HTTP fixture. Контракт и компоненты ReUI/base-nova не менялись; существующая таблица принимает отдельные DTO rows. State114/114, все8 профильных скриптов, backend build и scoped ESLint passed. E2E18 unique/35.9s: две новые проверки общего сертификата1280/320 плюс профильная регрессия. + +Before на backend113: native established с адресом198.18.114.250, две certificate identities, но каталог сохранил только вторую и присвоил ей адрес; первая отсутствовала. After на ux-refs114 обе строки присутствуют и unknown без активного адреса, конфигурационные250/251 сохраняются. Certificate PATCH/DELETE и identity DELETE с общим сертификатом вернули409. PATCH выбранной identity при одинаковых match-by=certificate условиях вернул502 с native400 «a matching identity already exists»; обе identity остались неизменными. Это ограничение RouterOS, успешное редактирование одновременно существующих идентичных match rules НЕ принято. После удаления собственного дубля исходная identity восстановила online/250; её API PATCH имени и возврат исходного имени дали200, native readback совпал. Два успешных PATCH вызывают запись истории с dedupe; число вставленных revisions отдельно не заявляется. + +Live browser4/4 без HTTP mocks: dark/light1280/320, обе реальные строки, unknown alert/сводка, decoy offline, reload, overflow0, pageerrors0/APIwrites0. Desktop light и mobile dark снимки просмотрены. Первичные helper ошибки сохранены: syntax до вызовов; DELETE с Content-Type без тела дал400 до guard; затем native matching conflict потребовал правильного критерия приёмки. Каждый запуск с сетевыми объектами завершил cleanup13 коллекций×2; предварительные отказы не выданы за passed. + +Backend только: ux-refs114/version1.0.0-test.refs114, base60b2e09/context build-sessions113 плюс два overrides; source и compiled bytehashes совпали. Frontend ux-alias112-version/version1.0.0-test.alias112 сохранён. Backend/PG healthy; frontend подтверждён браузером, собственного healthcheck нет. Rollback .env pre-refs114.env сохранён privately, старый образ оставлен. Собственные certificates/keys/imports/peers/identities/mode-config/profile/proposal/groups/templates/files удалены; исходные13 коллекций×2 совпали, default/public/metadata/195.195.195.195/32/logging сохранены. Оба CHR доступны; пароли прежние. + +Протокол: [shared certificate](ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json), [live UI](ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json). Следующий115 — UI для удаления только выбранной identity с сохранением общего сертификата; затем согласовать оставшиеся native/внешние ограничения и итоговую регрессию. Множественные реальные сессии одного ID, клиентские ОС, полнаяPKI, интеграции и multi-WAN не закрыты этим блоком. + +| Референс | Схема | +| --- | --- | +| [ReUI Filtering2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | Сертификат → все identity → отдельные строки/настройки → выбор по identity ID | +| [ReUI Sheet8](https://reui.io/preview/base/sheet-8?ref=mcp) | Выбранная identity → проверка ссылок → API409 без записи либо подтверждённая операция | diff --git a/docs/ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json b/docs/ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json new file mode 100644 index 00000000..73ece2d3 --- /dev/null +++ b/docs/ux-ui/acceptance/browser-ipsec-refs-2026-10-10.json @@ -0,0 +1,57 @@ +{ + "date": "2026-10-10", + "runtime": { + "backend": "ux-refs114", + "frontend": "ux-alias112-version" + }, + "httpMocks": false, + "cases": [ + { + "theme": "dark", + "width": 1280, + "rootThemeConfirmed": true, + "versionConfirmed": true, + "unconnectedCertificateOffline": true, + "ambiguousClientsUnknown": true, + "unknownOnlineCount": true, + "reloadConfirmed": true, + "noPageOverflow": true + }, + { + "theme": "dark", + "width": 320, + "rootThemeConfirmed": true, + "versionConfirmed": false, + "unconnectedCertificateOffline": true, + "ambiguousClientsUnknown": true, + "unknownOnlineCount": true, + "reloadConfirmed": true, + "noPageOverflow": true + }, + { + "theme": "light", + "width": 1280, + "rootThemeConfirmed": true, + "versionConfirmed": true, + "unconnectedCertificateOffline": true, + "ambiguousClientsUnknown": true, + "unknownOnlineCount": true, + "reloadConfirmed": true, + "noPageOverflow": true + }, + { + "theme": "light", + "width": 320, + "rootThemeConfirmed": true, + "versionConfirmed": false, + "unconnectedCertificateOffline": true, + "ambiguousClientsUnknown": true, + "unknownOnlineCount": true, + "reloadConfirmed": true, + "noPageOverflow": true + } + ], + "pageErrors": [], + "applicationWrites": [], + "scope": "Real certificate multiple identity ambiguity, unknown row statuses and online count, reload, desktop/mobile, two themes" +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json b/docs/ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json new file mode 100644 index 00000000..8a846527 --- /dev/null +++ b/docs/ux-ui/acceptance/ipsec-shared-certificate-2026-10-10.json @@ -0,0 +1,944 @@ +{ + "date": "2026-10-10", + "block": "12.5.114", + "local": { + "stateChecks": 114, + "profileScripts": 8, + "backendBuildPassed": true, + "eslintPassed": true, + "e2eUnique": 18, + "e2eSeconds": 35.9 + }, + "deployment": { + "date": "2026-10-10", + "baseCommit": "60b2e09", + "baseContext": "build-sessions113", + "context": "build-refs114", + "overrides": { + "backend/src/services/ipsec-live.ts": "4e1434d2146f363ff6866bb1b7b060dd1e9e1d0165eae8ec56de750cf11137d0", + "backend/src/routes/ipsec.ts": "548e282c1060b649a85d8378e305d333203591b4b3de72825e8337077fa02da3" + }, + "containers": { + "backend": { + "imageTag": "mmapp-test/backend:ux-refs114", + "imageId": "sha256:df345a34f4e178b8a15fe5c537173f33a79eb15681a5e62b76a4aa7dab0017a6", + "state": "running", + "health": "healthy" + }, + "frontend": { + "imageTag": "mmapp-test/frontend:ux-alias112-version", + "imageId": "sha256:4bf4cad8b3c990a7259f84cf27fd6bb9ee34e3db904223b559c0559dbbe094a2", + "state": "running", + "health": null + }, + "postgres": { + "imageTag": "postgres:18-alpine", + "imageId": "sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873", + "state": "running", + "health": "healthy" + } + }, + "compiled": { + "backend/dist/services/ipsec-live.js": "cb6aa07a39c4be990224c1e30741d9431c64b7ff662f6ccd9843827b4b5415c4", + "backend/dist/routes/ipsec.js": "fd6d44c755d2598ab718b0925f4cc852e9fc91efc483838d0507e7202ab87b2d" + } + }, + "sourceByteHashesMatch": true, + "compiledByteHashesMatch": true, + "before": { + "date": "2026-10-10", + "block": "12.5.114", + "name": "mm-ipsec114-1791640092983", + "cn": "mm-ipsec114-1791640092983", + "observations": [ + { + "serverId": 1, + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640092983", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "1s" + } + ], + "catalog": [ + { + "name": "CN=mm-ipsec114-1791640092983", + "commonName": "CN=mm-ipsec114-1791640092983", + "certName": "ipsec-user-mm-ipsec114-1791640092983-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640092983", + "commonName": "mm-ipsec114-1791640092983", + "certName": "ipsec-user-mm-ipsec114-1791640092983", + "online": true, + "activeAddress": "198.18.114.250" + } + ] + }, + { + "serverId": 2, + "active": [ + { + ".id": "*1", + "id": "194.226.121.150", + "state": "established", + "remote-address": "194.226.121.150", + "uptime": "1s" + } + ], + "catalog": [] + } + ], + "cleanup": [ + { + "serverId": 1, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + }, + { + "serverId": 2, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + } + ], + "databaseWrites": false, + "certificate": { + "name": "ipsec-user-mm-ipsec114-1791640092983", + "common-name": "mm-ipsec114-1791640092983", + "ca": "mm-ipsec114-1791640092983-ca", + "key-usage": "digital-signature,key-encipherment,tls-client" + }, + "certificates": [ + [ + { + "name": "mm-ipsec114-1791640092983-ca", + "trusted": "true", + "trust-store": "all", + "invalid-before": "2026-10-10 16:48:14", + "invalid-after": "2026-10-12 16:48:14", + "common-name": "mm-ipsec114-1791640092983-CA" + }, + { + "name": "mm-ipsec114-1791640092983-server", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640092983-ca", + "invalid-before": "2026-10-10 16:48:16", + "invalid-after": "2026-10-12 16:48:16", + "common-name": "194.226.121.150" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640092983", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640092983-ca", + "invalid-before": "2026-10-10 16:48:16", + "invalid-after": "2026-10-12 16:48:16", + "common-name": "mm-ipsec114-1791640092983" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640092983-decoy", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640092983-ca", + "invalid-before": "2026-10-10 16:48:18", + "invalid-after": "2026-10-12 16:48:18", + "common-name": "CN=mm-ipsec114-1791640092983" + } + ], + [ + { + "name": "mm-ipsec114-1791640092983-client-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:48:16", + "invalid-after": "2026-10-12 15:48:16", + "common-name": "mm-ipsec114-1791640092983" + }, + { + "name": "mm-ipsec114-1791640092983-ca-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:48:14", + "invalid-after": "2026-10-12 15:48:14", + "common-name": "mm-ipsec114-1791640092983-CA" + } + ] + ], + "catalogCorrect": true, + "dnPhase": { + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640092983", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "1s" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640092983", + "commonName": "CN=mm-ipsec114-1791640092983", + "certName": "ipsec-user-mm-ipsec114-1791640092983-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640092983", + "commonName": "mm-ipsec114-1791640092983", + "certName": "ipsec-user-mm-ipsec114-1791640092983", + "online": true, + "activeAddress": "198.18.114.250" + } + ], + "decoyOnline": false + }, + "fqdnPhase": { + "configuredRemoteId": "fqdn:mm-ipsec114-1791640092983.example", + "nativeId": "mm-ipsec114-1791640092983.example", + "assignedAddress": "198.18.114.250", + "originalOnline": true, + "decoyOnline": false + }, + "aliasName": "mm-ipsec114-1791640092983-psk", + "aliasPhase": { + "native": [ + { + "id": "mm-ipsec114-1791640092983.example", + "state": "established", + "dynamic-address": "198.18.114.250" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640092983", + "kind": "cert", + "online": false + }, + { + "name": "mm-ipsec114-1791640092983-psk", + "kind": "cert", + "online": true, + "activeAddress": "198.18.114.250" + } + ], + "summary": { + "clientsTotal": 2, + "clientsOnline": 1 + } + }, + "error": "Expected values to be strictly equal:\n\n1 !== 2\n" + }, + "native": { + "date": "2026-10-10", + "block": "12.5.114", + "name": "mm-ipsec114-1791640533938", + "cn": "mm-ipsec114-1791640533938", + "observations": [ + { + "serverId": 1, + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640533938", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "0s" + } + ], + "catalog": [ + { + "name": "CN=mm-ipsec114-1791640533938", + "commonName": "CN=mm-ipsec114-1791640533938", + "certName": "ipsec-user-mm-ipsec114-1791640533938-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640533938", + "commonName": "mm-ipsec114-1791640533938", + "certName": "ipsec-user-mm-ipsec114-1791640533938", + "online": true, + "activeAddress": "198.18.114.250" + } + ] + }, + { + "serverId": 2, + "active": [ + { + ".id": "*1", + "id": "194.226.121.150", + "state": "established", + "remote-address": "194.226.121.150", + "uptime": "0s" + } + ], + "catalog": [] + } + ], + "cleanup": [ + { + "serverId": 1, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + }, + { + "serverId": 2, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + } + ], + "databaseWrites": "Two successful API mutations invoke history recording; deduplication may avoid new rows", + "certificate": { + "name": "ipsec-user-mm-ipsec114-1791640533938", + "common-name": "mm-ipsec114-1791640533938", + "ca": "mm-ipsec114-1791640533938-ca", + "key-usage": "digital-signature,key-encipherment,tls-client" + }, + "certificates": [ + [ + { + "name": "mm-ipsec114-1791640533938-ca", + "trusted": "true", + "trust-store": "all", + "invalid-before": "2026-10-10 16:55:36", + "invalid-after": "2026-10-12 16:55:36", + "common-name": "mm-ipsec114-1791640533938-CA" + }, + { + "name": "mm-ipsec114-1791640533938-server", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640533938-ca", + "invalid-before": "2026-10-10 16:55:36", + "invalid-after": "2026-10-12 16:55:36", + "common-name": "194.226.121.150" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640533938", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640533938-ca", + "invalid-before": "2026-10-10 16:55:37", + "invalid-after": "2026-10-12 16:55:37", + "common-name": "mm-ipsec114-1791640533938" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640533938-decoy", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640533938-ca", + "invalid-before": "2026-10-10 16:55:37", + "invalid-after": "2026-10-12 16:55:37", + "common-name": "CN=mm-ipsec114-1791640533938" + } + ], + [ + { + "name": "mm-ipsec114-1791640533938-client-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:55:37", + "invalid-after": "2026-10-12 15:55:37", + "common-name": "mm-ipsec114-1791640533938" + }, + { + "name": "mm-ipsec114-1791640533938-ca-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:55:36", + "invalid-after": "2026-10-12 15:55:36", + "common-name": "mm-ipsec114-1791640533938-CA" + } + ] + ], + "catalogCorrect": true, + "dnPhase": { + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640533938", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "0s" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640533938", + "commonName": "CN=mm-ipsec114-1791640533938", + "certName": "ipsec-user-mm-ipsec114-1791640533938-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640533938", + "commonName": "mm-ipsec114-1791640533938", + "certName": "ipsec-user-mm-ipsec114-1791640533938", + "online": true, + "activeAddress": "198.18.114.250" + } + ], + "decoyOnline": false + }, + "fqdnPhase": { + "configuredRemoteId": "fqdn:mm-ipsec114-1791640533938.example", + "nativeId": "mm-ipsec114-1791640533938.example", + "assignedAddress": "198.18.114.250", + "originalOnline": true, + "decoyOnline": false + }, + "aliasName": "mm-ipsec114-1791640533938-psk", + "aliasPhase": { + "native": [ + { + "id": "mm-ipsec114-1791640533938.example", + "state": "established", + "dynamic-address": "198.18.114.250" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640533938", + "kind": "cert", + "online": false + }, + { + "name": "mm-ipsec114-1791640533938", + "kind": "cert", + "online": false, + "sessionAmbiguous": true + }, + { + "name": "mm-ipsec114-1791640533938-psk", + "kind": "cert", + "online": false, + "sessionAmbiguous": true + } + ], + "summary": { + "clientsTotal": 3, + "clientsOnline": 0, + "clientsSessionAmbiguous": 2 + } + }, + "renameReply": { + "error": "RouterOS: RouterOS API error 400 on /ip/ipsec/identity/*B: {\"detail\":\"failure: a matching identity already exists\",\"error\":400,\"message\":\"Bad Request\"}" + }, + "targetGuards": { + "certificatePatch409": true, + "certificateDelete409": true, + "sharedCertificateIdentityDelete409": true, + "selectedIdentityPatchNativeMatchingConflict": true, + "bothCommentsUnchanged": true + }, + "recovered": { + "originalOnline": true, + "sessionAmbiguous": false, + "activeAddress": "198.18.114.250", + "selectedIdentityPatch200AfterRemovingDuplicate": true, + "originalCommentRestored": true + } + }, + "browserEvidence": "browser-ipsec-refs-2026-10-10.json", + "preliminary": { + "syntaxErrorBeforeAnyWrites": "Identifier root already declared; corrected to apiRoot", + "emptyDeleteBody": { + "date": "2026-10-10", + "block": "12.5.114", + "name": "mm-ipsec114-1791640180482", + "cn": "mm-ipsec114-1791640180482", + "observations": [ + { + "serverId": 1, + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640180482", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "1s" + } + ], + "catalog": [ + { + "name": "CN=mm-ipsec114-1791640180482", + "commonName": "CN=mm-ipsec114-1791640180482", + "certName": "ipsec-user-mm-ipsec114-1791640180482-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640180482", + "commonName": "mm-ipsec114-1791640180482", + "certName": "ipsec-user-mm-ipsec114-1791640180482", + "online": true, + "activeAddress": "198.18.114.250" + } + ] + }, + { + "serverId": 2, + "active": [ + { + ".id": "*1", + "id": "194.226.121.150", + "state": "established", + "remote-address": "194.226.121.150", + "uptime": "1s" + } + ], + "catalog": [] + } + ], + "cleanup": [ + { + "serverId": 1, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + }, + { + "serverId": 2, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + } + ], + "databaseWrites": false, + "certificate": { + "name": "ipsec-user-mm-ipsec114-1791640180482", + "common-name": "mm-ipsec114-1791640180482", + "ca": "mm-ipsec114-1791640180482-ca", + "key-usage": "digital-signature,key-encipherment,tls-client" + }, + "certificates": [ + [ + { + "name": "mm-ipsec114-1791640180482-ca", + "trusted": "true", + "trust-store": "all", + "invalid-before": "2026-10-10 16:49:42", + "invalid-after": "2026-10-12 16:49:42", + "common-name": "mm-ipsec114-1791640180482-CA" + }, + { + "name": "mm-ipsec114-1791640180482-server", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640180482-ca", + "invalid-before": "2026-10-10 16:49:42", + "invalid-after": "2026-10-12 16:49:42", + "common-name": "194.226.121.150" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640180482", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640180482-ca", + "invalid-before": "2026-10-10 16:49:43", + "invalid-after": "2026-10-12 16:49:43", + "common-name": "mm-ipsec114-1791640180482" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640180482-decoy", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640180482-ca", + "invalid-before": "2026-10-10 16:49:44", + "invalid-after": "2026-10-12 16:49:44", + "common-name": "CN=mm-ipsec114-1791640180482" + } + ], + [ + { + "name": "mm-ipsec114-1791640180482-client-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:49:43", + "invalid-after": "2026-10-12 15:49:43", + "common-name": "mm-ipsec114-1791640180482" + }, + { + "name": "mm-ipsec114-1791640180482-ca-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:49:42", + "invalid-after": "2026-10-12 15:49:42", + "common-name": "mm-ipsec114-1791640180482-CA" + } + ] + ], + "catalogCorrect": true, + "dnPhase": { + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640180482", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "1s" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640180482", + "commonName": "CN=mm-ipsec114-1791640180482", + "certName": "ipsec-user-mm-ipsec114-1791640180482-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640180482", + "commonName": "mm-ipsec114-1791640180482", + "certName": "ipsec-user-mm-ipsec114-1791640180482", + "online": true, + "activeAddress": "198.18.114.250" + } + ], + "decoyOnline": false + }, + "fqdnPhase": { + "configuredRemoteId": "fqdn:mm-ipsec114-1791640180482.example", + "nativeId": "mm-ipsec114-1791640180482.example", + "assignedAddress": "198.18.114.250", + "originalOnline": true, + "decoyOnline": false + }, + "aliasName": "mm-ipsec114-1791640180482-psk", + "aliasPhase": { + "native": [ + { + "id": "mm-ipsec114-1791640180482.example", + "state": "established", + "dynamic-address": "198.18.114.250" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640180482", + "kind": "cert", + "online": false + }, + { + "name": "mm-ipsec114-1791640180482", + "kind": "cert", + "online": false, + "sessionAmbiguous": true + }, + { + "name": "mm-ipsec114-1791640180482-psk", + "kind": "cert", + "online": false, + "sessionAmbiguous": true + } + ], + "summary": { + "clientsTotal": 3, + "clientsOnline": 0, + "clientsSessionAmbiguous": 2 + } + }, + "error": "Expected values to be strictly equal:\n\n400 !== 409\n" + }, + "nativeMatchingConflict": { + "date": "2026-10-10", + "block": "12.5.114", + "name": "mm-ipsec114-1791640404881", + "cn": "mm-ipsec114-1791640404881", + "observations": [ + { + "serverId": 1, + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640404881", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "1s" + } + ], + "catalog": [ + { + "name": "CN=mm-ipsec114-1791640404881", + "commonName": "CN=mm-ipsec114-1791640404881", + "certName": "ipsec-user-mm-ipsec114-1791640404881-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640404881", + "commonName": "mm-ipsec114-1791640404881", + "certName": "ipsec-user-mm-ipsec114-1791640404881", + "online": true, + "activeAddress": "198.18.114.250" + } + ] + }, + { + "serverId": 2, + "active": [ + { + ".id": "*1", + "id": "194.226.121.150", + "state": "established", + "remote-address": "194.226.121.150", + "uptime": "0s" + } + ], + "catalog": [] + } + ], + "cleanup": [ + { + "serverId": 1, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + }, + { + "serverId": 2, + "/system/logging": true, + "/ip/ipsec/identity": true, + "/ip/ipsec/peer": true, + "/ip/ipsec/policy": true, + "/ip/ipsec/policy/group": true, + "/ip/ipsec/mode-config": true, + "/ip/ipsec/proposal": true, + "/ip/ipsec/profile": true, + "/certificate": true, + "/file": true, + "/ip/address": true, + "/ip/route": true, + "/ip/firewall/nat": true + } + ], + "databaseWrites": false, + "certificate": { + "name": "ipsec-user-mm-ipsec114-1791640404881", + "common-name": "mm-ipsec114-1791640404881", + "ca": "mm-ipsec114-1791640404881-ca", + "key-usage": "digital-signature,key-encipherment,tls-client" + }, + "certificates": [ + [ + { + "name": "mm-ipsec114-1791640404881-ca", + "trusted": "true", + "trust-store": "all", + "invalid-before": "2026-10-10 16:53:26", + "invalid-after": "2026-10-12 16:53:26", + "common-name": "mm-ipsec114-1791640404881-CA" + }, + { + "name": "mm-ipsec114-1791640404881-server", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640404881-ca", + "invalid-before": "2026-10-10 16:53:26", + "invalid-after": "2026-10-12 16:53:26", + "common-name": "194.226.121.150" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640404881", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640404881-ca", + "invalid-before": "2026-10-10 16:53:28", + "invalid-after": "2026-10-12 16:53:28", + "common-name": "mm-ipsec114-1791640404881" + }, + { + "name": "ipsec-user-mm-ipsec114-1791640404881-decoy", + "trusted": "false", + "trust-store": "all", + "ca": "mm-ipsec114-1791640404881-ca", + "invalid-before": "2026-10-10 16:53:29", + "invalid-after": "2026-10-12 16:53:29", + "common-name": "CN=mm-ipsec114-1791640404881" + } + ], + [ + { + "name": "mm-ipsec114-1791640404881-client-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:53:28", + "invalid-after": "2026-10-12 15:53:28", + "common-name": "mm-ipsec114-1791640404881" + }, + { + "name": "mm-ipsec114-1791640404881-ca-imported", + "trusted": "true", + "trust-store": "ipsec", + "invalid-before": "2026-10-10 15:53:26", + "invalid-after": "2026-10-12 15:53:26", + "common-name": "mm-ipsec114-1791640404881-CA" + } + ] + ], + "catalogCorrect": true, + "dnPhase": { + "active": [ + { + ".id": "*1", + "id": "CN=mm-ipsec114-1791640404881", + "state": "established", + "dynamic-address": "198.18.114.250", + "remote-address": "104.165.244.7", + "uptime": "1s" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640404881", + "commonName": "CN=mm-ipsec114-1791640404881", + "certName": "ipsec-user-mm-ipsec114-1791640404881-decoy", + "online": false + }, + { + "name": "mm-ipsec114-1791640404881", + "commonName": "mm-ipsec114-1791640404881", + "certName": "ipsec-user-mm-ipsec114-1791640404881", + "online": true, + "activeAddress": "198.18.114.250" + } + ], + "decoyOnline": false + }, + "fqdnPhase": { + "configuredRemoteId": "fqdn:mm-ipsec114-1791640404881.example", + "nativeId": "mm-ipsec114-1791640404881.example", + "assignedAddress": "198.18.114.250", + "originalOnline": true, + "decoyOnline": false + }, + "aliasName": "mm-ipsec114-1791640404881-psk", + "aliasPhase": { + "native": [ + { + "id": "mm-ipsec114-1791640404881.example", + "state": "established", + "dynamic-address": "198.18.114.250" + } + ], + "clients": [ + { + "name": "CN=mm-ipsec114-1791640404881", + "kind": "cert", + "online": false + }, + { + "name": "mm-ipsec114-1791640404881", + "kind": "cert", + "online": false, + "sessionAmbiguous": true + }, + { + "name": "mm-ipsec114-1791640404881-psk", + "kind": "cert", + "online": false, + "sessionAmbiguous": true + } + ], + "summary": { + "clientsTotal": 3, + "clientsOnline": 0, + "clientsSessionAmbiguous": 2 + } + }, + "renameReply": { + "error": "RouterOS: RouterOS API error 400 on /ip/ipsec/identity/*B: {\"detail\":\"failure: a matching identity already exists\",\"error\":400,\"message\":\"Bad Request\"}" + }, + "error": "{\"error\":\"RouterOS: RouterOS API error 400 on /ip/ipsec/identity/*B: {\\\"detail\\\":\\\"failure: a matching identity already exists\\\",\\\"error\\\":400,\\\"message\\\":\\\"Bad Request\\\"}\"}\n\n502 !== 200\n" + } + }, + "bounds": [ + "Two identities refer to one signed client certificate; native one established IKE connection", + "Selected PATCH with identical matching conditions is rejected by RouterOS; both comments unchanged", + "PATCH200 verified after removal of own duplicate, not while duplicate matching identities coexist", + "DELETE preserving certificate covered by local HTTP test; UI currently has no separate preserve-certificate option", + "Two successful API PATCH invoke history recording; do not claim two inserted history rows", + "No full regression, multiple simultaneous native sessions, client OS or full PKI claim" + ] +} diff --git a/docs/ux-ui/screens/ipsec-refs-dark-1280-2026-10-10.png b/docs/ux-ui/screens/ipsec-refs-dark-1280-2026-10-10.png new file mode 100644 index 00000000..1df77319 Binary files /dev/null and b/docs/ux-ui/screens/ipsec-refs-dark-1280-2026-10-10.png differ diff --git a/docs/ux-ui/screens/ipsec-refs-dark-320-2026-10-10.png b/docs/ux-ui/screens/ipsec-refs-dark-320-2026-10-10.png new file mode 100644 index 00000000..ab586d91 Binary files /dev/null and b/docs/ux-ui/screens/ipsec-refs-dark-320-2026-10-10.png differ diff --git a/docs/ux-ui/screens/ipsec-refs-light-1280-2026-10-10.png b/docs/ux-ui/screens/ipsec-refs-light-1280-2026-10-10.png new file mode 100644 index 00000000..0f871ee0 Binary files /dev/null and b/docs/ux-ui/screens/ipsec-refs-light-1280-2026-10-10.png differ diff --git a/docs/ux-ui/screens/ipsec-refs-light-320-2026-10-10.png b/docs/ux-ui/screens/ipsec-refs-light-320-2026-10-10.png new file mode 100644 index 00000000..62b8e765 Binary files /dev/null and b/docs/ux-ui/screens/ipsec-refs-light-320-2026-10-10.png differ diff --git a/tests/e2e/ipsec-data.spec.ts b/tests/e2e/ipsec-data.spec.ts index 6ce1665f..1ea9b631 100644 --- a/tests/e2e/ipsec-data.spec.ts +++ b/tests/e2e/ipsec-data.spec.ts @@ -6,6 +6,26 @@ const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0 const full = { servers: [summary("1"), summary("2")], clients: [client("1"), client("2")] } for (const width of [1280, 320]) { + test(`IPsec: один сертификат сохраняет разные строки identity ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 1000 }) + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + return r.fulfill({ json: path === "/api/ipsec" ? { + servers: [{ ...summary("1"), clientsTotal: 2, clientsSessionAmbiguous: 2 }], + clients: [0,1].map(n => ({ ...client("1", "Привязка " + n), id: "1:identity:*" + n, rosId: "identity:*" + n, certName: "shared-certificate", staticIp: "198.18.114." + (250+n), sessionAmbiguous: true })), + } : path === "/api/servers" ? servers : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ipsec?server=1") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(2) + for (const n of [0,1]) { + const row = rows.filter({ has: page.getByRole("button", { name: "Привязка " + n, exact: true }) }) + await expect(row).toContainText("198.18.114." + (250+n)) + await expect(row.getByText("Не определено", { exact: true })).toHaveCount(1) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + }) test(`IPsec: несколько сессий одного клиента без произвольного адреса ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 1000 }) let phase = 0